From 8161f27aaecd0b28830ff8486b29e82b379c0f11 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:01:37 -0400 Subject: [PATCH 01/11] Add cluster creation/deletion for jmp admin install/uninstall --- .../getting-started/installation/service.md | 57 ++- .../jumpstarter_cli_admin/delete.py | 2 +- .../jumpstarter_cli_admin/install.py | 331 +++++++++++++++--- .../jumpstarter-cli/jumpstarter_cli/config.py | 3 + .../jumpstarter_kubernetes/__init__.py | 20 +- .../jumpstarter_kubernetes/cluster.py | 192 ++++++++++ .../jumpstarter_kubernetes/install.py | 1 + .../jumpstarter/jumpstarter/common/ipaddr.py | 14 +- 8 files changed, 564 insertions(+), 56 deletions(-) create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py diff --git a/docs/source/getting-started/installation/service.md b/docs/source/getting-started/installation/service.md index e7915ddf8..1a6c8dda4 100644 --- a/docs/source/getting-started/installation/service.md +++ b/docs/source/getting-started/installation/service.md @@ -130,7 +130,11 @@ spec: ## Local Cluster -If you want to test our Jumpstarter locally, you can create a local cluster using tools such as [minikube](https://minikube.sigs.k8s.io/docs/start/) and [kind](https://kind.sigs.k8s.io/docs/user/quick-start/). +If you want to test Jumpstarter locally, you can create a local cluster using tools such as [minikube](https://minikube.sigs.k8s.io/docs/start/) and [kind](https://kind.sigs.k8s.io/docs/user/quick-start/). + +```{tip} +The quickest way to get started is using the [Jumpstarter admin CLI](#install-jumpstarter-with-the-cli) with the `--create-cluster` flag, which automatically creates and configures your local cluster. For manual cluster setup, continue reading below. +``` ````{tab} kind Kind is a tool for running local Kubernetes clusters using Podman or Docker @@ -213,7 +217,38 @@ cluster. Sometimes the automatic IP address detection for will not work correctly, to check if Jumpstarter can determine your IP address, run `jmp admin ip`. If the IP address cannot be determined, use the `--ip` argument to manually set your IP address. ``` -Install Jumpstarter with default options: +#### Create cluster and install Jumpstarter in one command + +The admin CLI can automatically create a local cluster and install Jumpstarter with a single command: + +````{tab} kind +```{code-block} console +$ jmp admin install --kind --create-cluster +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin install --minikube --create-cluster +``` +```` + +Additional options for cluster creation: + +- `--cluster-name`: Specify a custom cluster name (default: `jumpstarter-lab`) +- `--force-recreate-cluster`: Force recreate the cluster if it already exists (destroys all data) +- `--kind-extra-args`: Pass additional arguments to kind cluster creation +- `--minikube-extra-args`: Pass additional arguments to minikube cluster creation + +Example with custom cluster name: + +```{code-block} console +$ jmp admin install --kind --create-cluster --cluster-name my-jumpstarter-cluster +``` + +#### Install Jumpstarter on existing cluster + +If you already have a cluster running, install Jumpstarter with default options: ````{tab} kind ```{code-block} console @@ -227,12 +262,28 @@ $ jmp admin install --minikube ``` ```` +#### Uninstall Jumpstarter + Uninstall Jumpstarter with the CLI: ```{code-block} console $ jmp admin uninstall ``` +To also delete the local cluster when uninstalling, use the `--delete-cluster` flag: + +````{tab} kind +```{code-block} console +$ jmp admin uninstall --delete-cluster --kind +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin uninstall --delete-cluster --minikube +``` +```` + To check the status of the installation, run: ```{code-block} console @@ -301,5 +352,5 @@ jumpstarter-secrets-w42z4 0/1 Completed 0 48s To uninstall the Helm release, run: ```{code-block} console -helm uninstall jumpstarter --namespace jumpstarter-lab +$ helm uninstall jumpstarter --namespace jumpstarter-lab ``` diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py index 8144b6ea7..a49268010 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py @@ -26,7 +26,7 @@ @click.group(cls=AliasedGroup) def delete(): - """Create Jumpstarter Kubernetes objects""" + """Delete Jumpstarter Kubernetes objects""" @delete.command("client") diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py index e262ae073..8521215d6 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py @@ -1,25 +1,226 @@ -import shutil from typing import Literal, Optional import click from jumpstarter_cli_common.blocking import blocking from jumpstarter_cli_common.opt import opt_context, opt_kubeconfig -from jumpstarter_kubernetes import helm_installed, install_helm_chart, uninstall_helm_chart +from jumpstarter_kubernetes import ( + create_kind_cluster, + create_minikube_cluster, + delete_kind_cluster, + delete_minikube_cluster, + helm_installed, + install_helm_chart, + kind_installed, + minikube_installed, + uninstall_helm_chart, +) from .controller import get_latest_compatible_controller_version from jumpstarter.common.ipaddr import get_ip_address, get_minikube_ip -def minikube_installed() -> bool: - return shutil.which("minikube") is not None +def _validate_prerequisites(helm: str) -> None: + if helm_installed(helm) is False: + raise click.ClickException( + "helm is not installed (or not in your PATH), please specify a helm executable with --helm " + ) + + +def _validate_cluster_type( + kind: Optional[str], minikube: Optional[str] +) -> Optional[Literal["kind"] | Literal["minikube"]]: + if kind and minikube: + raise click.ClickException('You can only select one local cluster type "kind" or "minikube"') + + if kind is not None: + return "kind" + elif minikube is not None: + return "minikube" + return None + + +async def _configure_endpoints( + cluster_type: Optional[str], + minikube: str, + cluster_name: str, + ip: Optional[str], + basedomain: Optional[str], + grpc_endpoint: Optional[str], + router_endpoint: Optional[str], +) -> tuple[str, str, str, str]: + if ip is None: + ip = await get_ip_generic(cluster_type, minikube, cluster_name) + if basedomain is None: + basedomain = f"jumpstarter.{ip}.nip.io" + if grpc_endpoint is None: + grpc_endpoint = f"grpc.{basedomain}:8082" + if router_endpoint is None: + router_endpoint = f"router.{basedomain}:8083" + + return ip, basedomain, grpc_endpoint, router_endpoint + + +async def _handle_cluster_creation( + create_cluster: bool, + cluster_type: Optional[str], + force_recreate_cluster: bool, + cluster_name: str, + kind_extra_args: str, + minikube_extra_args: str, + kind: str, + minikube: str, +) -> None: + if not create_cluster: + return + + if cluster_type is None: + raise click.ClickException("--create-cluster requires either --kind or --minikube to be specified") + + if force_recreate_cluster: + click.echo(f'⚠️ WARNING: Force recreating cluster "{cluster_name}" will destroy ALL data in the cluster!') + click.echo("This includes:") + click.echo(" • All deployed applications and services") + click.echo(" • All persistent volumes and data") + click.echo(" • All configurations and secrets") + click.echo(" • All custom resources") + if not click.confirm(f'Are you sure you want to recreate cluster "{cluster_name}"?'): + click.echo("Cluster recreation cancelled.") + raise click.Abort() + + if cluster_type == "kind": + await _create_kind_cluster(kind, cluster_name, kind_extra_args, force_recreate_cluster) + elif cluster_type == "minikube": + await _create_minikube_cluster(minikube, cluster_name, minikube_extra_args, force_recreate_cluster) + +async def _create_kind_cluster( + kind: str, cluster_name: str, kind_extra_args: str, force_recreate_cluster: bool +) -> None: + if not kind_installed(kind): + raise click.ClickException("kind is not installed (or not in your PATH)") -async def get_ip_generic(cluster_type: Optional[str]) -> str: + cluster_action = "Recreating" if force_recreate_cluster else "Creating" + click.echo(f'{cluster_action} Kind cluster "{cluster_name}"...') + extra_args_list = kind_extra_args.split() if kind_extra_args.strip() else [] + try: + await create_kind_cluster(kind, cluster_name, extra_args_list, force_recreate_cluster) + if force_recreate_cluster: + click.echo(f'Successfully recreated Kind cluster "{cluster_name}"') + else: + click.echo(f'Successfully created Kind cluster "{cluster_name}"') + except RuntimeError as e: + if "already exists" in str(e) and not force_recreate_cluster: + click.echo(f'Kind cluster "{cluster_name}" already exists, continuing...') + else: + if force_recreate_cluster: + raise click.ClickException(f"Failed to recreate Kind cluster: {e}") from e + else: + raise click.ClickException(f"Failed to create Kind cluster: {e}") from e + + +async def _create_minikube_cluster( + minikube: str, cluster_name: str, minikube_extra_args: str, force_recreate_cluster: bool +) -> None: + if not minikube_installed(minikube): + raise click.ClickException("minikube is not installed (or not in your PATH)") + + cluster_action = "Recreating" if force_recreate_cluster else "Creating" + click.echo(f'{cluster_action} Minikube cluster "{cluster_name}"...') + extra_args_list = minikube_extra_args.split() if minikube_extra_args.strip() else [] + try: + await create_minikube_cluster(minikube, cluster_name, extra_args_list, force_recreate_cluster) + if force_recreate_cluster: + click.echo(f'Successfully recreated Minikube cluster "{cluster_name}"') + else: + click.echo(f'Successfully created Minikube cluster "{cluster_name}"') + except RuntimeError as e: + if "already exists" in str(e) and not force_recreate_cluster: + click.echo(f'Minikube cluster "{cluster_name}" already exists, continuing...') + else: + if force_recreate_cluster: + raise click.ClickException(f"Failed to recreate Minikube cluster: {e}") from e + else: + raise click.ClickException(f"Failed to create Minikube cluster: {e}") from e + + +async def _delete_kind_cluster(kind: str, cluster_name: str) -> None: + if not kind_installed(kind): + raise click.ClickException("kind is not installed (or not in your PATH)") + + click.echo(f'Deleting Kind cluster "{cluster_name}"...') + try: + await delete_kind_cluster(kind, cluster_name) + click.echo(f'Successfully deleted Kind cluster "{cluster_name}"') + except RuntimeError as e: + raise click.ClickException(f"Failed to delete Kind cluster: {e}") from e + + +async def _delete_minikube_cluster(minikube: str, cluster_name: str) -> None: + if not minikube_installed(minikube): + raise click.ClickException("minikube is not installed (or not in your PATH)") + + click.echo(f'Deleting Minikube cluster "{cluster_name}"...') + try: + await delete_minikube_cluster(minikube, cluster_name) + click.echo(f'Successfully deleted Minikube cluster "{cluster_name}"') + except RuntimeError as e: + raise click.ClickException(f"Failed to delete Minikube cluster: {e}") from e + + +async def _handle_cluster_deletion(kind: Optional[str], minikube: Optional[str], cluster_name: str) -> None: + cluster_type = _validate_cluster_type(kind, minikube) + + if cluster_type is None: + return + + if not click.confirm( + f'⚠️ WARNING: This will permanently delete the "{cluster_name}" {cluster_type} cluster and ALL its data. Continue?' # noqa: E501 + ): + click.echo("Cluster deletion cancelled.") + return + + if cluster_type == "kind": + await _delete_kind_cluster(kind or "kind", cluster_name) + elif cluster_type == "minikube": + await _delete_minikube_cluster(minikube or "minikube", cluster_name) + + +async def _install_jumpstarter_helm_chart( + chart: str, + name: str, + namespace: str, + basedomain: str, + grpc_endpoint: str, + router_endpoint: str, + mode: str, + version: str, + kubeconfig: Optional[str], + context: Optional[str], + helm: str, + ip: str, +) -> None: + click.echo(f'Installing Jumpstarter service v{version} in namespace "{namespace}" with Helm\n') + click.echo(f"Chart URI: {chart}") + click.echo(f"Chart Version: {version}") + click.echo(f"IP Address: {ip}") + click.echo(f"Basedomain: {basedomain}") + click.echo(f"Service Endpoint: {grpc_endpoint}") + click.echo(f"Router Endpoint: {router_endpoint}") + click.echo(f"gPRC Mode: {mode}\n") + + await install_helm_chart( + chart, name, namespace, basedomain, grpc_endpoint, router_endpoint, mode, version, kubeconfig, context, helm + ) + + click.echo(f'Installed Helm release "{name}" in namespace "{namespace}"') + + +async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_name: str = None) -> str: if cluster_type == "minikube": - if not minikube_installed(): + if not minikube_installed(minikube): raise click.ClickException("minikube is not installed (or not in your PATH)") try: - ip = await get_minikube_ip() + ip = await get_minikube_ip(cluster_name, minikube) except Exception as e: raise click.ClickException(f"Could not determine Minikube IP address.\n{e}") from e else: @@ -50,10 +251,25 @@ async def get_ip_generic(cluster_type: Optional[str]) -> str: @click.option("--nodeport", "mode", flag_value="nodeport", help="Use Nodeport routing (recommended)", default=True) @click.option("--ingress", "mode", flag_value="ingress", help="Use a Kubernetes ingress") @click.option("--route", "mode", flag_value="route", help="Use an OpenShift route") -@click.option("--kind", "cluster_type", flag_value="kind", help="Use default settings for a local Kind cluster") @click.option( - "--minikube", "cluster_type", flag_value="minikube", help="Use default settings for a local Minikube cluster" + "--kind", is_flag=False, flag_value="kind", default=None, help="Use default settings for a local Kind cluster" +) +@click.option( + "--minikube", + is_flag=False, + flag_value="minikube", + default=None, + help="Use default settings for a local Minikube cluster", ) +@click.option("--create-cluster", is_flag=True, help="Create a local Kind or Minikube cluster if it does not exist") +@click.option( + "--force-recreate-cluster", + is_flag=True, + help="Force recreate the cluster if it already exists (WARNING: This will destroy all data in the cluster)", +) +@click.option("--cluster-name", type=str, help="The name of the local cluster to create", default="jumpstarter-lab") +@click.option("--kind-extra-args", type=str, help="Extra arguments for the Kind cluster creation", default="") +@click.option("--minikube-extra-args", type=str, help="Extra arguments for the Minikube cluster creation", default="") @click.option("-v", "--version", help="The version of the service to install", default=None) @opt_kubeconfig @opt_context @@ -68,59 +284,67 @@ async def install( grpc_endpoint: Optional[str], router_endpoint: Optional[str], mode: Literal["nodeport"] | Literal["ingress"] | Literal["route"], - cluster_type: Optional[Literal["kind"] | Literal["minikube"]], + kind: Optional[str], + minikube: Optional[str], + create_cluster: bool, + force_recreate_cluster: bool, + cluster_name: str, + kind_extra_args: str, + minikube_extra_args: str, version: str, kubeconfig: Optional[str], context: Optional[str], ): """Install the Jumpstarter service in a Kubernetes cluster""" - # Check if helm is installed - if helm_installed(helm) is False: - raise click.ClickException( - "helm is not installed (or not in your PATH), please specify a helm executable with --helm " - ) + _validate_prerequisites(helm) - # Get the system IP address and hostnames - if ip is None: - ip = await get_ip_generic(cluster_type) - if basedomain is None: - basedomain = f"jumpstarter.{ip}.nip.io" - if grpc_endpoint is None: - grpc_endpoint = f"grpc.{basedomain}:8082" + cluster_type = _validate_cluster_type(kind, minikube) - if router_endpoint is None: - router_endpoint = f"router.{basedomain}:8083" + await _handle_cluster_creation( + create_cluster, + cluster_type, + force_recreate_cluster, + cluster_name, + kind_extra_args, + minikube_extra_args, + kind or "kind", + minikube or "minikube", + ) + + ip, basedomain, grpc_endpoint, router_endpoint = await _configure_endpoints( + cluster_type, minikube or "minikube", cluster_name, ip, basedomain, grpc_endpoint, router_endpoint + ) if version is None: version = await get_latest_compatible_controller_version() - click.echo(f'Installing Jumpstarter service v{version} in namespace "{namespace}" with Helm\n') - click.echo(f"Chart URI: {chart}") - click.echo(f"Chart Version: {version}") - click.echo(f"IP Address: {ip}") - click.echo(f"Basedomain: {basedomain}") - click.echo(f"Service Endpoint: {grpc_endpoint}") - click.echo(f"Router Endpoint: {router_endpoint}") - click.echo(f"gPRC Mode: {mode}\n") - - await install_helm_chart( - chart, name, namespace, basedomain, grpc_endpoint, router_endpoint, mode, version, kubeconfig, context, helm + await _install_jumpstarter_helm_chart( + chart, name, namespace, basedomain, grpc_endpoint, router_endpoint, mode, version, kubeconfig, context, helm, ip ) - click.echo(f'Installed Helm release "{name}" in namespace "{namespace}"') - @click.command -@click.option("--kind", "cluster_type", flag_value="kind", help="Use default settings for a local Kind cluster") @click.option( - "--minikube", "cluster_type", flag_value="minikube", help="Use default settings for a local Minikube cluster" + "--kind", is_flag=False, flag_value="kind", default=None, help="Use default settings for a local Kind cluster" +) +@click.option( + "--minikube", + is_flag=False, + flag_value="minikube", + default=None, + help="Use default settings for a local Minikube cluster", ) +@click.option("--cluster-name", type=str, help="The name of the cluster", default="jumpstarter-lab") @blocking async def ip( - cluster_type: Optional[Literal["kind"] | Literal["minikube"]], + kind: Optional[str], + minikube: Optional[str], + cluster_name: str, ): """Attempt to determine the IP address of your computer""" - ip = await get_ip_generic(cluster_type) + cluster_type = _validate_cluster_type(kind, minikube) + minikube_binary = minikube or "minikube" + ip = await get_ip_generic(cluster_type, minikube_binary, cluster_name) click.echo(ip) @@ -130,6 +354,18 @@ async def ip( @click.option( "-n", "--namespace", type=str, help="Namespace to install Jumpstarter components in", default="jumpstarter-lab" ) +@click.option("--delete-cluster", is_flag=True, help="Delete the local cluster after uninstalling") +@click.option( + "--kind", is_flag=False, flag_value="kind", default=None, help="Delete the local Kind cluster after uninstalling" +) +@click.option( + "--minikube", + is_flag=False, + flag_value="minikube", + default=None, + help="Delete the local Minikube cluster after uninstalling", +) +@click.option("--cluster-name", type=str, help="The name of the local cluster to delete", default="jumpstarter-lab") @opt_kubeconfig @opt_context @blocking @@ -137,18 +373,21 @@ async def uninstall( helm: str, name: str, namespace: str, + delete_cluster: bool, + kind: Optional[str], + minikube: Optional[str], + cluster_name: str, kubeconfig: Optional[str], context: Optional[str], ): """Uninstall the Jumpstarter service in a Kubernetes cluster""" - # Check if helm is installed - if helm_installed(helm) is False: - raise click.ClickException( - "helm is not installed (or not in your PATH), please specify a helm executable with --helm " - ) + _validate_prerequisites(helm) click.echo(f'Uninstalling Jumpstarter service in namespace "{namespace}" with Helm') await uninstall_helm_chart(name, namespace, kubeconfig, context, helm) click.echo(f'Uninstalled Helm release "{name}" from namespace "{namespace}"') + + if delete_cluster: + await _handle_cluster_deletion(kind, minikube, cluster_name) diff --git a/packages/jumpstarter-cli/jumpstarter_cli/config.py b/packages/jumpstarter-cli/jumpstarter_cli/config.py index e31765cc7..652f7fd09 100644 --- a/packages/jumpstarter-cli/jumpstarter_cli/config.py +++ b/packages/jumpstarter-cli/jumpstarter_cli/config.py @@ -6,6 +6,9 @@ @click.group def config(): + """ + Manage local configurations + """ pass diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 59dc1618b..2d4058fac 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -1,4 +1,12 @@ from .clients import ClientsV1Alpha1Api, V1Alpha1Client, V1Alpha1ClientList, V1Alpha1ClientStatus +from .cluster import ( + create_kind_cluster, + create_minikube_cluster, + delete_kind_cluster, + delete_minikube_cluster, + kind_installed, + minikube_installed, +) from .exporters import ( ExportersV1Alpha1Api, V1Alpha1Exporter, @@ -6,7 +14,11 @@ V1Alpha1ExporterList, V1Alpha1ExporterStatus, ) -from .install import helm_installed, install_helm_chart, uninstall_helm_chart +from .install import ( + helm_installed, + install_helm_chart, + uninstall_helm_chart, +) from .leases import ( LeasesV1Alpha1Api, V1Alpha1Lease, @@ -37,4 +49,10 @@ "helm_installed", "install_helm_chart", "uninstall_helm_chart", + "minikube_installed", + "kind_installed", + "create_minikube_cluster", + "create_kind_cluster", + "delete_minikube_cluster", + "delete_kind_cluster", ] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py new file mode 100644 index 000000000..74d4dc14e --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py @@ -0,0 +1,192 @@ +import asyncio +import shutil +from typing import Optional, Tuple + + +def minikube_installed(minikube: str) -> bool: + """Check if Minikube is installed and available in the PATH.""" + return shutil.which(minikube) is not None + + +def kind_installed(kind: str) -> bool: + """Check if Kind is installed and available in the PATH.""" + return shutil.which(kind) is not None + + +async def run_command(cmd: list[str]) -> Tuple[int, str, str]: + """Run a command and return exit code, stdout, stderr""" + try: + process = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + return process.returncode, stdout.decode().strip(), stderr.decode().strip() + except FileNotFoundError as e: + raise RuntimeError(f"Command not found: {cmd[0]}") from e + + +async def run_command_with_output(cmd: list[str]) -> int: + """Run a command with real-time output streaming and return exit code""" + try: + process = await asyncio.create_subprocess_exec(*cmd) + return await process.wait() + except FileNotFoundError as e: + raise RuntimeError(f"Command not found: {cmd[0]}") from e + + +async def minikube_cluster_exists(minikube: str, cluster_name: str) -> bool: + """Check if a Minikube cluster exists.""" + if not minikube_installed(minikube): + return False + + try: + returncode, _, _ = await run_command([minikube, "status", "-p", cluster_name]) + return returncode == 0 + except RuntimeError: + return False + + +async def kind_cluster_exists(kind: str, cluster_name: str) -> bool: + """Check if a Kind cluster exists.""" + if not kind_installed(kind): + return False + + try: + returncode, _, _ = await run_command([kind, "get", "kubeconfig", "--name", cluster_name]) + return returncode == 0 + except RuntimeError: + return False + + +async def delete_minikube_cluster(minikube: str, cluster_name: str) -> bool: + """Delete a Minikube cluster.""" + if not minikube_installed(minikube): + raise RuntimeError(f"{minikube} is not installed or not found in PATH.") + + if not await minikube_cluster_exists(minikube, cluster_name): + return True # Already deleted, consider it successful + + returncode = await run_command_with_output([minikube, "delete", "-p", cluster_name]) + + if returncode == 0: + return True + else: + raise RuntimeError(f"Failed to delete Minikube cluster '{cluster_name}'") + + +async def delete_kind_cluster(kind: str, cluster_name: str) -> bool: + """Delete a Kind cluster.""" + if not kind_installed(kind): + raise RuntimeError(f"{kind} is not installed or not found in PATH.") + + if not await kind_cluster_exists(kind, cluster_name): + return True # Already deleted, consider it successful + + returncode = await run_command_with_output([kind, "delete", "cluster", "--name", cluster_name]) + + if returncode == 0: + return True + else: + raise RuntimeError(f"Failed to delete Kind cluster '{cluster_name}'") + + +async def create_minikube_cluster( + minikube: str, cluster_name: str, extra_args: Optional[list[str]] = None, force_recreate: bool = False +) -> bool: + """Create a Minikube cluster.""" + if extra_args is None: + extra_args = [] + + if not minikube_installed(minikube): + raise RuntimeError(f"{minikube} is not installed or not found in PATH.") + + # Check if cluster already exists + cluster_exists = await minikube_cluster_exists(minikube, cluster_name) + + if cluster_exists: + if not force_recreate: + raise RuntimeError(f"Minikube cluster '{cluster_name}' already exists.") + else: + if not await delete_minikube_cluster(minikube, cluster_name): + return False + + command = [ + minikube, + "start", + "--profile", + cluster_name, + "--extra-config=apiserver.service-node-port-range=8000-9000", + ] + command.extend(extra_args) + + returncode = await run_command_with_output(command) + + if returncode == 0: + return True + else: + raise RuntimeError(f"Failed to create Minikube cluster '{cluster_name}'") + + +async def create_kind_cluster( + kind: str, cluster_name: str, extra_args: Optional[list[str]] = None, force_recreate: bool = False +) -> bool: + """Create a Kind cluster.""" + if extra_args is None: + extra_args = [] + + if not kind_installed(kind): + raise RuntimeError(f"{kind} is not installed or not found in PATH.") + + # Check if cluster already exists + cluster_exists = await kind_cluster_exists(kind, cluster_name) + + if cluster_exists: + if not force_recreate: + raise RuntimeError(f"Kind cluster '{cluster_name}' already exists.") + else: + if not await delete_kind_cluster(kind, cluster_name): + return False + + cluster_config = """kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +kubeadmConfigPatches: +- | + kind: ClusterConfiguration + apiServer: + extraArgs: + "service-node-port-range": "3000-32767" +- | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" +nodes: +- role: control-plane + extraPortMappings: + - containerPort: 80 + hostPort: 5080 + protocol: TCP + - containerPort: 30010 + hostPort: 8082 + protocol: TCP + - containerPort: 30011 + hostPort: 8083 + protocol: TCP + - containerPort: 443 + hostPort: 5443 + protocol: TCP +""" + + command = [kind, "create", "cluster", "--name", cluster_name, "--config=/dev/stdin"] + command.extend(extra_args) + + kind_process = await asyncio.create_subprocess_exec( + *command, stdin=asyncio.subprocess.PIPE + ) + + await kind_process.communicate(input=cluster_config.encode()) + + if kind_process.returncode == 0: + return True + else: + raise RuntimeError(f"Failed to create Kind cluster '{cluster_name}'") diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py index 6cbafa58f..dc52d60c8 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py @@ -4,6 +4,7 @@ def helm_installed(name: str) -> bool: + """Check if Helm is installed and available in the PATH.""" return shutil.which(name) is not None diff --git a/packages/jumpstarter/jumpstarter/common/ipaddr.py b/packages/jumpstarter/jumpstarter/common/ipaddr.py index 7338f9d5d..752f652d8 100644 --- a/packages/jumpstarter/jumpstarter/common/ipaddr.py +++ b/packages/jumpstarter/jumpstarter/common/ipaddr.py @@ -26,20 +26,24 @@ def get_ip_address(logger: logging.Logger | None = None) -> str: return address -async def get_minikube_ip(): - # Create the subprocess +async def get_minikube_ip(profile: str = None, minikube: str = "minikube"): + # Create the subprocess with optional profile + cmd = [minikube, "ip"] + if profile: + cmd.extend(["-p", profile]) + process = await asyncio.create_subprocess_exec( - "minikube", "ip", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) # Wait for it to complete and get the output - stdout, _ = await process.communicate() + stdout, stderr = await process.communicate() # Decode and strip whitespace result = stdout.decode().strip() # Optional: check if command was successful if process.returncode != 0: - raise RuntimeError(stdout.decode()) + raise RuntimeError(stderr.decode()) return result From 4503943b02f01bab484e8424bd2c07ab1c005ec4 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:01:49 -0400 Subject: [PATCH 02/11] Add more detailed service installation instructions --- .../guides/setup-distributed-mode.md | 4 +- .../getting-started/installation/index.md | 4 +- .../getting-started/installation/service.md | 356 ------------------ .../installation/service/index.md | 20 + .../installation/service/service-local.md | 234 ++++++++++++ .../service/service-production.md | 332 ++++++++++++++++ 6 files changed, 590 insertions(+), 360 deletions(-) delete mode 100644 docs/source/getting-started/installation/service.md create mode 100644 docs/source/getting-started/installation/service/index.md create mode 100644 docs/source/getting-started/installation/service/service-local.md create mode 100644 docs/source/getting-started/installation/service/service-production.md diff --git a/docs/source/getting-started/guides/setup-distributed-mode.md b/docs/source/getting-started/guides/setup-distributed-mode.md index ce34cdae2..ab2faa356 100644 --- a/docs/source/getting-started/guides/setup-distributed-mode.md +++ b/docs/source/getting-started/guides/setup-distributed-mode.md @@ -25,9 +25,9 @@ environment: These driver packages include mock implementations, enabling you to test the connection between an exporter and client without physical hardware. -You need the [service](../../introduction/service.md) running in a Kubernetes +You need the [service](../../introduction/service/index.md) running in a Kubernetes cluster with admin access. For installation instructions, refer to the -[installation guide](../installation/service.md). +[installation guide](../installation/service/index.md). ## Instructions diff --git a/docs/source/getting-started/installation/index.md b/docs/source/getting-started/installation/index.md index edddcb930..2a7756e50 100644 --- a/docs/source/getting-started/installation/index.md +++ b/docs/source/getting-started/installation/index.md @@ -4,11 +4,11 @@ This section provides guidance on installing Jumpstarter components in your environment. The guides cover: - [Packages](packages.md): Installing Jumpstarter software packages -- [Service](service.md): Setting up Jumpstarter as a Kubernetes service +- [Service](service/index.md): Setting up Jumpstarter as a Kubernetes service ```{toctree} :maxdepth: 1 :hidden: packages.md -service.md +service/index.md ``` \ No newline at end of file diff --git a/docs/source/getting-started/installation/service.md b/docs/source/getting-started/installation/service.md deleted file mode 100644 index 1a6c8dda4..000000000 --- a/docs/source/getting-started/installation/service.md +++ /dev/null @@ -1,356 +0,0 @@ -# Service - -This section explains how to install and configure the Jumpstarter service in -your Kubernetes cluster. The service enables centralized management of your -Jumpstarter environment. Before installing, ensure you have: - -- A Kubernetes cluster available -- `kubectl` installed and configured to access your cluster -- [Helm](https://helm.sh/docs/intro/install/) (version 3.x or newer) -- Administrator access to your cluster (required for CRD installation) -- Domain name for service endpoints (or use `nip.io` for local testing) - -```{note} -`global.baseDomain` creates these service hostnames with `jumpstarter.example.com`: -- `grpc.jumpstarter.example.com` -- `router.jumpstarter.example.com` (for router endpoints) -``` - -## Kubernetes with Helm - -Install Jumpstarter on a standard Kubernetes cluster or OpenShift using Helm: - -````{tab} Kubernetes -```{code-block} console -:substitutions: -$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ - --create-namespace --namespace jumpstarter-lab \ - --set global.baseDomain=jumpstarter.example.com \ - --set global.metrics.enabled=true \ - --set jumpstarter-controller.grpc.mode=ingress \ - --version={{controller_version}} -``` -```` - -````{tab} OpenShift -```{code-block} console -:substitutions: -$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ - --create-namespace --namespace jumpstarter-lab \ - --set global.baseDomain=jumpstarter.example.com \ - --set global.metrics.enabled=true \ - --set jumpstarter-controller.grpc.mode=route \ - --version={{controller_version}} -``` -```` - -## Install with OpenShift and ArgoCD - -You can also use ArgoCD to install Jumpstarter in your OpenShift cluster: - -First, create and label a namespace for Jumpstarter: - -```console -$ kubectl create namespace jumpstarter-lab -$ kubectl label namespace jumpstarter-lab argocd.argoproj.io/managed-by=openshift-gitops -``` - -For ArgoCD to manage Jumpstarter CRDs, create this `ClusterRole` and `ClusterRoleBinding`: - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - annotations: - argocds.argoproj.io/name: openshift-gitops - argocds.argoproj.io/namespace: openshift-gitops - name: openshift-gitops-argocd-appcontroller-crd -rules: -- apiGroups: - - 'apiextensions.k8s.io' - resources: - - 'customresourcedefinitions' - verbs: - - '*' ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - annotations: - argocds.argoproj.io/name: openshift-gitops - argocds.argoproj.io/namespace: openshift-gitops - name: openshift-gitops-argocd-appcontroller-crd -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: openshift-gitops-argocd-appcontroller-crd -subjects: -- kind: ServiceAccount - name: openshift-gitops-argocd-application-controller - namespace: openshift-gitops -``` - -Create an ArgoCD Application to deploy Jumpstarter: - -```{warning} -The secrets `jumpstarter-controller.controllerSecret` and `jumpstarter-controller.routerSecret` -must be unique for each installation. While Helm can auto-generate these, ArgoCD cannot - -you must manually create these in your Jumpstarter namespace. -``` - -```{code-block} yaml -:substitutions: -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: jumpstarter - namespace: openshift-gitops -spec: - destination: - name: in-cluster - namespace: jumpstarter-lab - project: default - source: - chart: jumpstarter - helm: - parameters: - - name: global.baseDomain - value: devel.jumpstarter.dev - - name: global.metrics.enabled - value: "true" - - name: jumpstarter-controller.controllerSecret - value: "pick-a-secret-DONT-USE-THIS-DEFAULT" - - name: jumpstarter-controller.routerSecret - value: "again-pick-a-secret-DONT-USE-THIS-DEFAULT" - - name: jumpstarter-controller.grpc.mode - value: "route" - repoURL: quay.io/jumpstarter-dev/helm - targetRevision: "{{controller_version}}" -``` - -## Local Cluster - -If you want to test Jumpstarter locally, you can create a local cluster using tools such as [minikube](https://minikube.sigs.k8s.io/docs/start/) and [kind](https://kind.sigs.k8s.io/docs/user/quick-start/). - -```{tip} -The quickest way to get started is using the [Jumpstarter admin CLI](#install-jumpstarter-with-the-cli) with the `--create-cluster` flag, which automatically creates and configures your local cluster. For manual cluster setup, continue reading below. -``` - -````{tab} kind -Kind is a tool for running local Kubernetes clusters using Podman or Docker -containerized "nodes". - -```{tip} -Consider minikube for environments requiring [untrusted certificates](https://minikube.sigs.k8s.io/docs/handbook/untrusted_certs/). -``` - -Find more information on the [kind -website](https://kind.sigs.k8s.io/docs/user/quick-start/). - -### Create a kind cluster - -First, create a kind cluster config that enables nodeports to host the Services. -Save this as `kind_config.yaml`: - -```{code-block} yaml -kind: Cluster -apiVersion: kind.x-k8s.io/v1alpha4 -kubeadmConfigPatches: -- | - kind: ClusterConfiguration - apiServer: - extraArgs: - "service-node-port-range": "3000-32767" -- | - kind: InitConfiguration - nodeRegistration: - kubeletExtraArgs: - node-labels: "ingress-ready=true" -nodes: -- role: control-plane - extraPortMappings: - - containerPort: 80 - hostPort: 5080 - protocol: TCP - - containerPort: 30010 - hostPort: 8082 - protocol: TCP - - containerPort: 30011 - hostPort: 8083 - protocol: TCP - - containerPort: 443 - hostPort: 5443 - protocol: TCP -``` - -Next, create a kind cluster using the config you created: - -```{code-block} console -$ kind create cluster --config kind_config.yaml -``` -```` - -````{tab} minikube -Minikube runs local Kubernetes clusters using VMs or container "nodes". It works -across several platforms and supports different hypervisors, making it ideal for -local development and testing. - -Find more information on the [minikube -website](https://minikube.sigs.k8s.io/docs/start/). - -### Create a minikube cluster - -Expand the default NodePort range to include the Jumpstarter ports: - -```{code-block} console -$ minikube start --extra-config=apiserver.service-node-port-range=8000-9000 -``` -```` - -### Install Jumpstarter with the CLI - -The Jumpstarter CLI provides the `jmp admin install` command to automatically -run Helm with the correct arguments, simplifying installation in your Kubernetes -cluster. - -```{warning} -Sometimes the automatic IP address detection for will not work correctly, to check if Jumpstarter can determine your IP address, run `jmp admin ip`. If the IP address cannot be determined, use the `--ip` argument to manually set your IP address. -``` - -#### Create cluster and install Jumpstarter in one command - -The admin CLI can automatically create a local cluster and install Jumpstarter with a single command: - -````{tab} kind -```{code-block} console -$ jmp admin install --kind --create-cluster -``` -```` - -````{tab} minikube -```{code-block} console -$ jmp admin install --minikube --create-cluster -``` -```` - -Additional options for cluster creation: - -- `--cluster-name`: Specify a custom cluster name (default: `jumpstarter-lab`) -- `--force-recreate-cluster`: Force recreate the cluster if it already exists (destroys all data) -- `--kind-extra-args`: Pass additional arguments to kind cluster creation -- `--minikube-extra-args`: Pass additional arguments to minikube cluster creation - -Example with custom cluster name: - -```{code-block} console -$ jmp admin install --kind --create-cluster --cluster-name my-jumpstarter-cluster -``` - -#### Install Jumpstarter on existing cluster - -If you already have a cluster running, install Jumpstarter with default options: - -````{tab} kind -```{code-block} console -$ jmp admin install --kind -``` -```` - -````{tab} minikube -```{code-block} console -$ jmp admin install --minikube -``` -```` - -#### Uninstall Jumpstarter - -Uninstall Jumpstarter with the CLI: - -```{code-block} console -$ jmp admin uninstall -``` - -To also delete the local cluster when uninstalling, use the `--delete-cluster` flag: - -````{tab} kind -```{code-block} console -$ jmp admin uninstall --delete-cluster --kind -``` -```` - -````{tab} minikube -```{code-block} console -$ jmp admin uninstall --delete-cluster --minikube -``` -```` - -To check the status of the installation, run: - -```{code-block} console -$ kubectl get pods -n jumpstarter-lab --watch -NAME READY STATUS RESTARTS AGE -jumpstarter-controller-cc74d879-6b22b 1/1 Running 0 48s -jumpstarter-secrets-w42z4 0/1 Completed 0 48s -``` - -For complete documentation of the `jmp admin install` command and all available -options, see the [MAN pages](../../reference/man-pages/jmp.md). - -### Install Jumpstarter with Helm - -For manual installation with Helm, use these commands: - -````{tab} kind -```{code-block} console -:substitutions: -$ export IP="X.X.X.X" # Enter the IP address of your computer on the local network -$ export BASEDOMAIN="jumpstarter.${IP}.nip.io" -$ export GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" -$ export GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" -$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ - --create-namespace --namespace jumpstarter-lab \ - --set global.baseDomain=${BASEDOMAIN} \ - --set jumpstarter-controller.grpc.endpoint=${GRPC_ENDPOINT} \ - --set jumpstarter-controller.grpc.routerEndpoint=${GRPC_ROUTER_ENDPOINT} \ - --set global.metrics.enabled=false \ - --set jumpstarter-controller.grpc.nodeport.enabled=true \ - --set jumpstarter-controller.grpc.mode=nodeport \ - --version={{controller_version}} -``` -```` - -````{tab} minikube -```{code-block} console -:substitutions: -$ export IP=$(minikube ip) -$ export BASEDOMAIN="jumpstarter.${IP}.nip.io" -$ export GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" -$ export GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" -$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ - --create-namespace --namespace jumpstarter-lab \ - --set global.baseDomain=${BASEDOMAIN} \ - --set jumpstarter-controller.grpc.endpoint=${GRPC_ENDPOINT} \ - --set jumpstarter-controller.grpc.routerEndpoint=${GRPC_ROUTER_ENDPOINT} \ - --set global.metrics.enabled=false \ - --set jumpstarter-controller.grpc.nodeport.enabled=true \ - --set jumpstarter-controller.grpc.nodeport.port=8082 \ - --set jumpstarter-controller.grpc.nodeport.routerPort=8083 \ - --set jumpstarter-controller.grpc.mode=nodeport \ - --version={{controller_version}} -``` -```` - -To check the status of the installation, run: - -```{code-block} console -$ kubectl get pods -n jumpstarter-lab --watch -NAME READY STATUS RESTARTS AGE -jumpstarter-controller-cc74d879-6b22b 1/1 Running 0 48s -jumpstarter-secrets-w42z4 0/1 Completed 0 48s -``` - -To uninstall the Helm release, run: - -```{code-block} console -$ helm uninstall jumpstarter --namespace jumpstarter-lab -``` diff --git a/docs/source/getting-started/installation/service/index.md b/docs/source/getting-started/installation/service/index.md new file mode 100644 index 000000000..8e60d0147 --- /dev/null +++ b/docs/source/getting-started/installation/service/index.md @@ -0,0 +1,20 @@ +# Service + +This section explains how to install and configure the Jumpstarter service in your Kubernetes cluster. The service enables centralized management of your Jumpstarter environment. + +## Getting Started + +For most users, we recommend starting with a **local installation** to get familiar with Jumpstarter before moving to production deployments. + +```{toctree} +:maxdepth: 2 + +service-local.md +service-production.md +``` + +## Quick Start + +**New to Jumpstarter?** Start with the [Local Installation](service-local.md) guide to get up and running quickly on your development machine. + +**Ready for production?** See the [Production Deployment](service-production.md) guide for Kubernetes and OpenShift clusters with proper security, monitoring, and ingress configurations. diff --git a/docs/source/getting-started/installation/service/service-local.md b/docs/source/getting-started/installation/service/service-local.md new file mode 100644 index 000000000..1eed0b603 --- /dev/null +++ b/docs/source/getting-started/installation/service/service-local.md @@ -0,0 +1,234 @@ +# Local Installation + +For local development and testing, you can install Jumpstarter on local Kubernetes clusters using tools like kind or minikube. This is ideal for getting started quickly or for CI/CD pipelines. + +## Prerequisites + +Before installing locally, ensure you have: + +- Docker or Podman installed (for kind) +- `kubectl` installed and configured to access your cluster +- [Helm](https://helm.sh/docs/intro/install/) (version 3.x or newer) +- Administrator access to your cluster (required for CRD installation) + +## Install with Jumpstarter CLI + +The Jumpstarter CLI provides the `jmp admin install` command to automatically +run Helm with the correct arguments, simplifying installation in your Kubernetes +cluster. This is the recommended approach for getting started quickly. + +```{warning} +Sometimes the automatic IP address detection for will not work correctly, to check if Jumpstarter can determine your IP address, run `jmp admin ip`. If the IP address cannot be determined, use the `--ip` argument to manually set your IP address. +``` + +### Create a Local Cluster and Install Jumpstarter + +If you want to test Jumpstarter locally with more control over the setup, you can create a local cluster using tools such as [minikube](https://minikube.sigs.k8s.io/docs/start/) and [kind](https://kind.sigs.k8s.io/docs/user/quick-start/). + +[**kind**](https://kind.sigs.k8s.io/docs/user/quick-start/) (Kubernetes in Docker) is a tool for running local Kubernetes clusters using Docker or Podman containerized "nodes". It's lightweight and fast to start, making it excellent for CI/CD pipelines and quick local testing. + +[**minikube**](https://minikube.sigs.k8s.io/docs/start/) runs local Kubernetes clusters using VMs or container "nodes". It works across several platforms and supports different hypervisors, making it ideal for local development and testing. It's particularly useful in environments requiring untrusted certificates. + +```{tip} +Consider minikube for environments requiring [untrusted certificates](https://minikube.sigs.k8s.io/docs/handbook/untrusted_certs/). +``` + +The admin CLI can automatically create a local cluster and install Jumpstarter with a single command: + +````{tab} kind +```{code-block} console +$ jmp admin install --kind --create-cluster +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin install --minikube --create-cluster +``` +```` + +Additional options for cluster creation: + +- `--cluster-name`: Specify a custom cluster name (default: `jumpstarter-lab`) +- `--force-recreate-cluster`: Force recreate the cluster if it already exists (destroys all data) +- `--kind-extra-args`: Pass additional arguments to kind cluster creation +- `--minikube-extra-args`: Pass additional arguments to minikube cluster creation +- `--kind-extra-args`: Pass additional arguments to kind cluster creation + +To set a custom cluster name: + +````{tab} kind +```{code-block} console +$ jmp admin install --kind --create-cluster --cluster-name my-jumpstarter-cluster +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin install --minikube --create-cluster --cluster-name my-jumpstarter-cluster +``` +```` + +### Install Jumpstarter in an Existing Local Cluster + +```{warning} +Jumpstarter requires specific `NodePort` configurations, it is recommended to create a new cluster for Jumpstarter or use the automatic creation above. +``` + +If you already have a local cluster, install Jumpstarter with default options for your local cluster tool: + +````{tab} kind +```{code-block} console +$ jmp admin install --kind +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin install --minikube +``` +```` + +### Uninstall Jumpstarter + +Uninstall Jumpstarter with the CLI: + +```{code-block} console +$ jmp admin uninstall +``` + +To delete the local cluster when uninstalling, use the `--delete-cluster` flag: + +````{tab} kind +```{code-block} console +$ jmp admin uninstall --kind --delete-cluster +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin uninstall --minikube --delete-cluster +``` +```` + +For complete documentation of the `jmp admin install` command and all available +options, see the [MAN pages](../../reference/man-pages/jmp.md). + +## Manual Local Cluster Install + +If you want to customize the local cluster further, you can create the cluster yourself. + +### Create a Local Cluster + +````{tab} kind +#### Create a kind cluster + +First, create a kind cluster config that enables nodeports to host the Services. +Save this as `kind_config.yaml`: + +```{code-block} yaml +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +kubeadmConfigPatches: +- | + kind: ClusterConfiguration + apiServer: + extraArgs: + "service-node-port-range": "3000-32767" +- | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" +nodes: +- role: control-plane + extraPortMappings: + - containerPort: 80 + hostPort: 5080 + protocol: TCP + - containerPort: 30010 + hostPort: 8082 + protocol: TCP + - containerPort: 30011 + hostPort: 8083 + protocol: TCP + - containerPort: 443 + hostPort: 5443 + protocol: TCP +``` + +Next, create a kind cluster using the config you created: + +```{code-block} console +$ kind create cluster --config kind_config.yaml +``` +```` + +````{tab} minikube +#### Create a minikube cluster + +Expand the default NodePort range to include the Jumpstarter ports: + +```{code-block} console +$ minikube start --extra-config=apiserver.service-node-port-range=8000-9000 +``` +```` + +### Install Local Jumpstarter with Helm + +For manual installation with Helm, use these commands: + +````{tab} kind +```{code-block} console +:substitutions: +$ export IP="X.X.X.X" # Enter the IP address of your computer on the local network +$ export BASEDOMAIN="jumpstarter.${IP}.nip.io" +$ export GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" +$ export GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" +$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ + --create-namespace --namespace jumpstarter-lab \ + --set global.baseDomain=${BASEDOMAIN} \ + --set jumpstarter-controller.grpc.endpoint=${GRPC_ENDPOINT} \ + --set jumpstarter-controller.grpc.routerEndpoint=${GRPC_ROUTER_ENDPOINT} \ + --set global.metrics.enabled=false \ + --set jumpstarter-controller.grpc.nodeport.enabled=true \ + --set jumpstarter-controller.grpc.mode=nodeport \ + --version={{controller_version}} +``` +```` + +````{tab} minikube +```{code-block} console +:substitutions: +$ export IP=$(minikube ip) +$ export BASEDOMAIN="jumpstarter.${IP}.nip.io" +$ export GRPC_ENDPOINT="grpc.${BASEDOMAIN}:8082" +$ export GRPC_ROUTER_ENDPOINT="router.${BASEDOMAIN}:8083" +$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ + --create-namespace --namespace jumpstarter-lab \ + --set global.baseDomain=${BASEDOMAIN} \ + --set jumpstarter-controller.grpc.endpoint=${GRPC_ENDPOINT} \ + --set jumpstarter-controller.grpc.routerEndpoint=${GRPC_ROUTER_ENDPOINT} \ + --set global.metrics.enabled=false \ + --set jumpstarter-controller.grpc.nodeport.enabled=true \ + --set jumpstarter-controller.grpc.nodeport.port=8082 \ + --set jumpstarter-controller.grpc.nodeport.routerPort=8083 \ + --set jumpstarter-controller.grpc.mode=nodeport \ + --version={{controller_version}} +``` +```` + +To check the status of the installation, run: + +```{code-block} console +$ kubectl get pods -n jumpstarter-lab --watch +NAME READY STATUS RESTARTS AGE +jumpstarter-controller-cc74d879-6b22b 1/1 Running 0 48s +jumpstarter-secrets-w42z4 0/1 Completed 0 48s +``` + +To uninstall the Helm release, run: + +```{code-block} console +$ helm uninstall jumpstarter --namespace jumpstarter-lab +``` \ No newline at end of file diff --git a/docs/source/getting-started/installation/service/service-production.md b/docs/source/getting-started/installation/service/service-production.md new file mode 100644 index 000000000..c65af3a4c --- /dev/null +++ b/docs/source/getting-started/installation/service/service-production.md @@ -0,0 +1,332 @@ +# Production Deployment + +For production deployments, you can install Jumpstarter on Kubernetes or OpenShift clusters with proper ingress, monitoring, and security configurations. + +## Prerequisites + +Before installing in production, ensure you have: + +- A production Kubernetes cluster available +- `kubectl` installed and configured to access your cluster +- [Helm](https://helm.sh/docs/intro/install/) (version 3.x or newer) +- Administrator access to your cluster (required for CRD installation) +- Domain name for service endpoints +- Ingress controller installed (for Kubernetes) or Routes configured (for OpenShift) +- TLS certificates for your domain (or cert-manager for automatic certificate management) + +```{note} +`global.baseDomain` creates these service hostnames with `jumpstarter.example.com`: +- `grpc.jumpstarter.example.com` +- `router.jumpstarter.example.com` (for router endpoints) +``` + +## TLS and gRPC Configuration + +Jumpstarter uses gRPC for communication, which has specific requirements for production deployments: + +### gRPC Requirements + +- **HTTP/2 Support**: gRPC requires HTTP/2, ensure your ingress controller or load balancer supports it +- **gRPC Protocol**: Some ingress controllers require specific annotations for gRPC traffic +- **Keep-Alive Settings**: Long-lived gRPC connections may need keep-alive configuration +- **Load Balancing**: Use consistent hashing or session affinity for gRPC connections + +### TLS Termination Options + +Choose one of these TLS termination approaches: + +**Option 1: TLS Termination at Ingress/Route (Recommended)** +- Terminate TLS at the ingress controller or OpenShift route +- Simpler certificate management +- Better performance with fewer encryption hops + +**Option 2: End-to-End TLS** +- TLS from client to Jumpstarter service +- Higher security but more complex certificate management +- Required for strict compliance environments + +```{warning} +gRPC over HTTP/1.1 is not supported. Ensure your ingress controller supports HTTP/2 and is properly configured for gRPC traffic. +``` + +### Ingress Controller Configuration Examples + +Different ingress controllers require specific annotations for gRPC traffic: + +**NGINX Ingress Controller:** +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/backend-protocol: "GRPC" + nginx.ingress.kubernetes.io/grpc-backend: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" # If using cert-manager +``` + +**AWS Load Balancer Controller (ALB):** +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/backend-protocol-version: GRPC + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:region:account:certificate/cert-id +``` + +**Istio Gateway:** +```yaml +apiVersion: networking.istio.io/v1beta1 +kind: Gateway +metadata: + name: jumpstarter-gateway +spec: + servers: + - port: + number: 443 + name: https-grpc + protocol: HTTPS + tls: + mode: SIMPLE + credentialName: jumpstarter-tls-secret +``` + +## Install with Helm + +Install Jumpstarter on a Kubernetes/OpenShift cluster using Helm: + +````{tab} Kubernetes +```{code-block} console +:substitutions: +$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ + --create-namespace --namespace jumpstarter-lab \ + --set global.baseDomain=jumpstarter.example.com \ + --set global.metrics.enabled=true \ + --set jumpstarter-controller.grpc.mode=ingress \ + --version={{controller_version}} +``` +```` + +````{tab} OpenShift +```{code-block} console +:substitutions: +$ helm upgrade jumpstarter --install oci://quay.io/jumpstarter-dev/helm/jumpstarter \ + --create-namespace --namespace jumpstarter-lab \ + --set global.baseDomain=jumpstarter.example.com \ + --set global.metrics.enabled=true \ + --set jumpstarter-controller.grpc.mode=route \ + --version={{controller_version}} +``` + +**OpenShift Route TLS Configuration:** + +OpenShift automatically creates secure routes with TLS termination. For custom certificates: + +```yaml +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: jumpstarter-grpc + annotations: + haproxy.router.openshift.io/balance: source + haproxy.router.openshift.io/timeout: 300s +spec: + host: grpc.jumpstarter.example.com + tls: + termination: edge + certificate: | + -----BEGIN CERTIFICATE----- + # Your certificate here + -----END CERTIFICATE----- + key: | + -----BEGIN PRIVATE KEY----- + # Your private key here + -----END PRIVATE KEY----- + to: + kind: Service + name: jumpstarter-controller-grpc + weight: 100 +``` +```` + +## Install with ArgoCD + +You can use ArgoCD to install Jumpstarter in your production cluster. Below are examples for different platforms: + +````{tab} Kubernetes +### Install with ArgoCD on Kubernetes (Amazon EKS) + +First, create a namespace for Jumpstarter: + +```console +$ kubectl create namespace jumpstarter-lab +``` + +If your ArgoCD installation requires namespace labeling for management, add the appropriate label: + +```console +$ kubectl label namespace jumpstarter-lab argocd.argoproj.io/managed-by=argocd +``` + +For ArgoCD to manage Jumpstarter CRDs, create this `ClusterRole` and `ClusterRoleBinding`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: argocd-application-controller-crd +rules: +- apiGroups: + - 'apiextensions.k8s.io' + resources: + - 'customresourcedefinitions' + verbs: + - '*' +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: argocd-application-controller-crd +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: argocd-application-controller-crd +subjects: +- kind: ServiceAccount + name: argocd-application-controller + namespace: argocd # Replace with your ArgoCD namespace +``` + +Create an ArgoCD Application to deploy Jumpstarter: + +```{warning} +The secrets `jumpstarter-controller.controllerSecret` and `jumpstarter-controller.routerSecret` +must be unique for each installation. While Helm can auto-generate these, ArgoCD cannot - +you must manually create these in your Jumpstarter namespace. +``` + +```{code-block} yaml +:substitutions: +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: jumpstarter + namespace: argocd # Replace with your ArgoCD namespace +spec: + destination: + name: in-cluster + namespace: jumpstarter-lab + project: default + source: + chart: jumpstarter + helm: + parameters: + - name: global.baseDomain + value: jumpstarter.example.com + - name: global.metrics.enabled + value: "true" + - name: jumpstarter-controller.controllerSecret + value: "pick-a-secret-DONT-USE-THIS-DEFAULT" + - name: jumpstarter-controller.routerSecret + value: "again-pick-a-secret-DONT-USE-THIS-DEFAULT" + - name: jumpstarter-controller.grpc.mode + value: "ingress" + repoURL: quay.io/jumpstarter-dev/helm + targetRevision: "{{controller_version}}" + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` +```` + +````{tab} OpenShift +### Install with ArgoCD on OpenShift + +First, create and label a namespace for Jumpstarter: + +```console +$ kubectl create namespace jumpstarter-lab +$ kubectl label namespace jumpstarter-lab argocd.argoproj.io/managed-by=openshift-gitops +``` + +For ArgoCD to manage Jumpstarter CRDs, create this `ClusterRole` and `ClusterRoleBinding`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + annotations: + argocds.argoproj.io/name: openshift-gitops + argocds.argoproj.io/namespace: openshift-gitops + name: openshift-gitops-argocd-appcontroller-crd +rules: +- apiGroups: + - 'apiextensions.k8s.io' + resources: + - 'customresourcedefinitions' + verbs: + - '*' +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + annotations: + argocds.argoproj.io/name: openshift-gitops + argocds.argoproj.io/namespace: openshift-gitops + name: openshift-gitops-argocd-appcontroller-crd +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: openshift-gitops-argocd-appcontroller-crd +subjects: +- kind: ServiceAccount + name: openshift-gitops-argocd-application-controller + namespace: openshift-gitops +``` + +Create an ArgoCD Application to deploy Jumpstarter: + +```{warning} +The secrets `jumpstarter-controller.controllerSecret` and `jumpstarter-controller.routerSecret` +must be unique for each installation. While Helm can auto-generate these, ArgoCD cannot - +you must manually create these in your Jumpstarter namespace. +``` + +```{code-block} yaml +:substitutions: +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: jumpstarter + namespace: openshift-gitops +spec: + destination: + name: in-cluster + namespace: jumpstarter-lab + project: default + source: + chart: jumpstarter + helm: + parameters: + - name: global.baseDomain + value: jumpstarter.example.com + - name: global.metrics.enabled + value: "true" + - name: jumpstarter-controller.controllerSecret + value: "pick-a-secret-DONT-USE-THIS-DEFAULT" + - name: jumpstarter-controller.routerSecret + value: "again-pick-a-secret-DONT-USE-THIS-DEFAULT" + - name: jumpstarter-controller.grpc.mode + value: "route" + repoURL: quay.io/jumpstarter-dev/helm + targetRevision: "{{controller_version}}" +``` +```` \ No newline at end of file From df3e6ec78771392c7ee7aa0584b9502bf71acb8e Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:09:17 -0400 Subject: [PATCH 03/11] Fix linting errors --- packages/jumpstarter/jumpstarter/common/ipaddr.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/jumpstarter/jumpstarter/common/ipaddr.py b/packages/jumpstarter/jumpstarter/common/ipaddr.py index 752f652d8..70944048d 100644 --- a/packages/jumpstarter/jumpstarter/common/ipaddr.py +++ b/packages/jumpstarter/jumpstarter/common/ipaddr.py @@ -31,10 +31,8 @@ async def get_minikube_ip(profile: str = None, minikube: str = "minikube"): cmd = [minikube, "ip"] if profile: cmd.extend(["-p", profile]) - - process = await asyncio.create_subprocess_exec( - *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) + + process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) # Wait for it to complete and get the output stdout, stderr = await process.communicate() From ddf67f9bfe705cb51822bc5b088730730fcb7268 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:13:11 -0400 Subject: [PATCH 04/11] Fix documentation links --- docs/source/getting-started/guides/setup-distributed-mode.md | 2 +- .../getting-started/installation/service/service-local.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/getting-started/guides/setup-distributed-mode.md b/docs/source/getting-started/guides/setup-distributed-mode.md index ab2faa356..fe02306be 100644 --- a/docs/source/getting-started/guides/setup-distributed-mode.md +++ b/docs/source/getting-started/guides/setup-distributed-mode.md @@ -25,7 +25,7 @@ environment: These driver packages include mock implementations, enabling you to test the connection between an exporter and client without physical hardware. -You need the [service](../../introduction/service/index.md) running in a Kubernetes +You need the [service](../../introduction/service.md) running in a Kubernetes cluster with admin access. For installation instructions, refer to the [installation guide](../installation/service/index.md). diff --git a/docs/source/getting-started/installation/service/service-local.md b/docs/source/getting-started/installation/service/service-local.md index 1eed0b603..6a6bba339 100644 --- a/docs/source/getting-started/installation/service/service-local.md +++ b/docs/source/getting-started/installation/service/service-local.md @@ -112,7 +112,7 @@ $ jmp admin uninstall --minikube --delete-cluster ```` For complete documentation of the `jmp admin install` command and all available -options, see the [MAN pages](../../reference/man-pages/jmp.md). +options, see the [MAN pages](../../../reference/man-pages/jmp.md). ## Manual Local Cluster Install From 9b0448493c68b30841814ca372c685b499a4c770 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:28:54 -0400 Subject: [PATCH 05/11] Add tests for cluster install/uninstall --- .../jumpstarter_cli_admin/install_test.py | 693 ++++++++++++++++++ .../jumpstarter_kubernetes/cluster_test.py | 395 ++++++++++ .../jumpstarter/common/ipaddr_test.py | 77 ++ 3 files changed, 1165 insertions(+) create mode 100644 packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py create mode 100644 packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py create mode 100644 packages/jumpstarter/jumpstarter/common/ipaddr_test.py diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py new file mode 100644 index 000000000..d700935ee --- /dev/null +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py @@ -0,0 +1,693 @@ +from unittest.mock import patch + +import click +import pytest +from click.testing import CliRunner + +from jumpstarter_cli_admin.install import ( + _configure_endpoints, + _create_kind_cluster, + _create_minikube_cluster, + _delete_kind_cluster, + _delete_minikube_cluster, + _handle_cluster_creation, + _handle_cluster_deletion, + _validate_cluster_type, + _validate_prerequisites, + get_ip_generic, + install, + uninstall, +) + + +class TestValidationFunctions: + """Test validation helper functions.""" + + @patch("jumpstarter_cli_admin.install.helm_installed") + def test_validate_prerequisites_helm_installed(self, mock_helm_installed): + mock_helm_installed.return_value = True + # Should not raise any exception + _validate_prerequisites("helm") + + @patch("jumpstarter_cli_admin.install.helm_installed") + def test_validate_prerequisites_helm_not_installed(self, mock_helm_installed): + mock_helm_installed.return_value = False + with pytest.raises(click.ClickException, match="helm is not installed"): + _validate_prerequisites("helm") + + def test_validate_cluster_type_both_specified(self): + with pytest.raises(click.ClickException, match="You can only select one local cluster type"): + _validate_cluster_type("kind", "minikube") + + def test_validate_cluster_type_kind_only(self): + result = _validate_cluster_type("kind", None) + assert result == "kind" + + def test_validate_cluster_type_minikube_only(self): + result = _validate_cluster_type(None, "minikube") + assert result == "minikube" + + def test_validate_cluster_type_neither(self): + result = _validate_cluster_type(None, None) + assert result is None + + +class TestEndpointConfiguration: + """Test endpoint configuration logic.""" + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.get_ip_generic") + async def test_configure_endpoints_all_defaults(self, mock_get_ip): + mock_get_ip.return_value = "192.168.1.100" + + ip, basedomain, grpc_endpoint, router_endpoint = await _configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain=None, + grpc_endpoint=None, + router_endpoint=None, + ) + + assert ip == "192.168.1.100" + assert basedomain == "jumpstarter.192.168.1.100.nip.io" + assert grpc_endpoint == "grpc.jumpstarter.192.168.1.100.nip.io:8082" + assert router_endpoint == "router.jumpstarter.192.168.1.100.nip.io:8083" + + @pytest.mark.asyncio + async def test_configure_endpoints_all_provided(self): + ip, basedomain, grpc_endpoint, router_endpoint = await _configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip="10.0.0.1", + basedomain="example.com", + grpc_endpoint="grpc.example.com:9000", + router_endpoint="router.example.com:9001", + ) + + assert ip == "10.0.0.1" + assert basedomain == "example.com" + assert grpc_endpoint == "grpc.example.com:9000" + assert router_endpoint == "router.example.com:9001" + + +class TestClusterCreation: + """Test cluster creation logic.""" + + @pytest.mark.asyncio + async def test_handle_cluster_creation_not_requested(self): + # Should return early without doing anything + await _handle_cluster_creation( + create_cluster=False, + cluster_type=None, + force_recreate_cluster=False, + cluster_name="test", + kind_extra_args="", + minikube_extra_args="", + kind="kind", + minikube="minikube", + ) + + @pytest.mark.asyncio + async def test_handle_cluster_creation_no_cluster_type(self): + with pytest.raises(click.ClickException, match="--create-cluster requires either --kind or --minikube"): + await _handle_cluster_creation( + create_cluster=True, + cluster_type=None, + force_recreate_cluster=False, + cluster_name="test", + kind_extra_args="", + minikube_extra_args="", + kind="kind", + minikube="minikube", + ) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install._create_kind_cluster") + async def test_handle_cluster_creation_kind(self, mock_create_kind): + await _handle_cluster_creation( + create_cluster=True, + cluster_type="kind", + force_recreate_cluster=False, + cluster_name="test-cluster", + kind_extra_args="--verbosity=1", + minikube_extra_args="", + kind="kind", + minikube="minikube", + ) + + mock_create_kind.assert_called_once_with("kind", "test-cluster", "--verbosity=1", False) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install._create_minikube_cluster") + async def test_handle_cluster_creation_minikube(self, mock_create_minikube): + await _handle_cluster_creation( + create_cluster=True, + cluster_type="minikube", + force_recreate_cluster=False, + cluster_name="test-cluster", + kind_extra_args="", + minikube_extra_args="--memory=4096", + kind="kind", + minikube="minikube", + ) + + mock_create_minikube.assert_called_once_with("minikube", "test-cluster", "--memory=4096", False) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.click.confirm") + @patch("jumpstarter_cli_admin.install._create_kind_cluster") + async def test_handle_cluster_creation_force_recreate_confirmed(self, mock_create_kind, mock_confirm): + mock_confirm.return_value = True + + await _handle_cluster_creation( + create_cluster=True, + cluster_type="kind", + force_recreate_cluster=True, + cluster_name="test-cluster", + kind_extra_args="", + minikube_extra_args="", + kind="kind", + minikube="minikube", + ) + + mock_confirm.assert_called_once() + mock_create_kind.assert_called_once_with("kind", "test-cluster", "", True) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.click.confirm") + async def test_handle_cluster_creation_force_recreate_cancelled(self, mock_confirm): + mock_confirm.return_value = False + + with pytest.raises(click.Abort): + await _handle_cluster_creation( + create_cluster=True, + cluster_type="kind", + force_recreate_cluster=True, + cluster_name="test-cluster", + kind_extra_args="", + minikube_extra_args="", + kind="kind", + minikube="minikube", + ) + + +class TestSpecificClusterCreation: + """Test specific cluster creation functions.""" + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.kind_installed") + @patch("jumpstarter_cli_admin.install.create_kind_cluster") + async def test_create_kind_cluster_success(self, mock_create_kind, mock_kind_installed): + mock_kind_installed.return_value = True + mock_create_kind.return_value = True + + await _create_kind_cluster("kind", "test-cluster", "--verbosity=1", False) + + mock_create_kind.assert_called_once_with("kind", "test-cluster", ["--verbosity=1"], False) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.kind_installed") + async def test_create_kind_cluster_not_installed(self, mock_kind_installed): + mock_kind_installed.return_value = False + + with pytest.raises(click.ClickException, match="kind is not installed"): + await _create_kind_cluster("kind", "test-cluster", "", False) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.kind_installed") + @patch("jumpstarter_cli_admin.install.create_kind_cluster") + async def test_create_kind_cluster_failure(self, mock_create_kind, mock_kind_installed): + mock_kind_installed.return_value = True + mock_create_kind.side_effect = RuntimeError("Creation failed") + + with pytest.raises(click.ClickException, match="Failed to create Kind cluster"): + await _create_kind_cluster("kind", "test-cluster", "", False) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.minikube_installed") + @patch("jumpstarter_cli_admin.install.create_minikube_cluster") + async def test_create_minikube_cluster_success(self, mock_create_minikube, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_create_minikube.return_value = True + + await _create_minikube_cluster("minikube", "test-cluster", "--memory=4096", False) + + mock_create_minikube.assert_called_once_with("minikube", "test-cluster", ["--memory=4096"], False) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.minikube_installed") + async def test_create_minikube_cluster_not_installed(self, mock_minikube_installed): + mock_minikube_installed.return_value = False + + with pytest.raises(click.ClickException, match="minikube is not installed"): + await _create_minikube_cluster("minikube", "test-cluster", "", False) + + +class TestIPDetection: + """Test IP address detection functions.""" + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.get_minikube_ip") + @patch("jumpstarter_cli_admin.install.get_ip_address") + async def test_get_ip_generic_minikube(self, mock_get_ip_address, mock_get_minikube_ip): + mock_get_minikube_ip.return_value = "192.168.49.2" + + result = await get_ip_generic("minikube", "minikube", "test-cluster") + + assert result == "192.168.49.2" + mock_get_minikube_ip.assert_called_once_with("test-cluster", "minikube") + mock_get_ip_address.assert_not_called() + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.get_ip_address") + async def test_get_ip_generic_kind(self, mock_get_ip_address): + mock_get_ip_address.return_value = "192.168.1.100" + + result = await get_ip_generic("kind", "minikube", "test-cluster") + + assert result == "192.168.1.100" + mock_get_ip_address.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.get_ip_address") + async def test_get_ip_generic_none(self, mock_get_ip_address): + mock_get_ip_address.return_value = "192.168.1.100" + + result = await get_ip_generic(None, "minikube", "test-cluster") + + assert result == "192.168.1.100" + mock_get_ip_address.assert_called_once() + + +class TestInstallCommand: + """Test the main install CLI command.""" + + def setup_method(self): + self.runner = CliRunner() + + @patch("jumpstarter_cli_admin.install.helm_installed") + def test_install_command_helm_not_installed(self, mock_helm_installed): + mock_helm_installed.return_value = False + + result = self.runner.invoke(install, []) + + assert result.exit_code != 0 + assert "helm is not installed" in result.output + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install._configure_endpoints") + @patch("jumpstarter_cli_admin.install._handle_cluster_creation") + @patch("jumpstarter_cli_admin.install.install_helm_chart") + @patch("jumpstarter_cli_admin.install.get_latest_compatible_controller_version") + def test_install_command_success_minimal( + self, + mock_get_version, + mock_install_helm, + mock_handle_cluster, + mock_configure_endpoints, + mock_validate_cluster, + mock_helm_installed, + ): + mock_helm_installed.return_value = True + mock_validate_cluster.return_value = None + mock_configure_endpoints.return_value = ( + "192.168.1.100", + "jumpstarter.192.168.1.100.nip.io", + "grpc.jumpstarter.192.168.1.100.nip.io:8082", + "router.jumpstarter.192.168.1.100.nip.io:8083", + ) + mock_get_version.return_value = "1.0.0" + mock_install_helm.return_value = None + + result = self.runner.invoke(install, []) + + assert result.exit_code == 0 + mock_install_helm.assert_called_once() + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + def test_install_command_both_cluster_types(self, mock_validate_cluster, mock_helm_installed): + mock_helm_installed.return_value = True + mock_validate_cluster.side_effect = click.ClickException("You can only select one local cluster type") + + result = self.runner.invoke(install, ["--kind", "kind", "--minikube", "minikube"]) + + assert result.exit_code != 0 + assert "You can only select one local cluster type" in result.output + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install._configure_endpoints") + @patch("jumpstarter_cli_admin.install._handle_cluster_creation") + @patch("jumpstarter_cli_admin.install.install_helm_chart") + @patch("jumpstarter_cli_admin.install.get_latest_compatible_controller_version") + def test_install_command_with_kind_create_cluster( + self, + mock_get_version, + mock_install_helm, + mock_handle_cluster, + mock_configure_endpoints, + mock_validate_cluster, + mock_helm_installed, + ): + mock_helm_installed.return_value = True + mock_validate_cluster.return_value = "kind" + mock_configure_endpoints.return_value = ( + "192.168.1.100", + "jumpstarter.192.168.1.100.nip.io", + "grpc.jumpstarter.192.168.1.100.nip.io:8082", + "router.jumpstarter.192.168.1.100.nip.io:8083", + ) + mock_get_version.return_value = "1.0.0" + mock_install_helm.return_value = None + + result = self.runner.invoke(install, ["--kind", "kind", "--create-cluster"]) + + assert result.exit_code == 0 + mock_handle_cluster.assert_called_once() + # Verify cluster creation was called with correct parameters + args = mock_handle_cluster.call_args[0] # positional args + assert args[0] is True # create_cluster + assert args[1] == "kind" # cluster_type + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install._configure_endpoints") + @patch("jumpstarter_cli_admin.install._handle_cluster_creation") + @patch("jumpstarter_cli_admin.install.install_helm_chart") + @patch("jumpstarter_cli_admin.install.get_latest_compatible_controller_version") + def test_install_command_with_custom_options( + self, + mock_get_version, + mock_install_helm, + mock_handle_cluster, + mock_configure_endpoints, + mock_validate_cluster, + mock_helm_installed, + ): + mock_helm_installed.return_value = True + mock_validate_cluster.return_value = "minikube" + mock_configure_endpoints.return_value = ( + "10.0.0.1", + "custom.example.com", + "grpc.custom.example.com:9000", + "router.custom.example.com:9001", + ) + mock_get_version.return_value = "1.0.0" + mock_install_helm.return_value = None + + result = self.runner.invoke( + install, + [ + "--minikube", + "minikube", + "--create-cluster", + "--cluster-name", + "custom-cluster", + "--force-recreate-cluster", + "--ip", + "10.0.0.1", + "--basedomain", + "custom.example.com", + "--grpc-endpoint", + "grpc.custom.example.com:9000", + "--router-endpoint", + "router.custom.example.com:9001", + "--minikube-extra-args", + "--memory=4096", + ], + ) + + assert result.exit_code == 0 + + # Verify cluster creation was called with custom parameters + cluster_args = mock_handle_cluster.call_args[0] # positional args + assert cluster_args[3] == "custom-cluster" # cluster_name + assert cluster_args[2] is True # force_recreate_cluster + assert cluster_args[5] == "--memory=4096" # minikube_extra_args + + # Verify endpoint configuration was called with custom values + endpoint_args = mock_configure_endpoints.call_args[0] # positional args + assert endpoint_args[2] == "custom-cluster" # cluster_name + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install._configure_endpoints") + @patch("jumpstarter_cli_admin.install._handle_cluster_creation") + @patch("jumpstarter_cli_admin.install.install_helm_chart") + @patch("jumpstarter_cli_admin.install.get_latest_compatible_controller_version") + def test_install_command_helm_installation_failure( + self, + mock_get_version, + mock_install_helm, + mock_handle_cluster, + mock_configure_endpoints, + mock_validate_cluster, + mock_helm_installed, + ): + mock_helm_installed.return_value = True + mock_validate_cluster.return_value = None + mock_configure_endpoints.return_value = ( + "192.168.1.100", + "jumpstarter.192.168.1.100.nip.io", + "grpc.jumpstarter.192.168.1.100.nip.io:8082", + "router.jumpstarter.192.168.1.100.nip.io:8083", + ) + mock_get_version.return_value = "1.0.0" + mock_install_helm.side_effect = RuntimeError("Helm installation failed") + + result = self.runner.invoke(install, []) + + assert result.exit_code != 0 + assert result.exception # Should have an exception + + def test_install_command_help(self): + result = self.runner.invoke(install, ["--help"]) + + assert result.exit_code == 0 + assert "Install Jumpstarter" in result.output or "Usage:" in result.output + assert "--create-cluster" in result.output + assert "--kind" in result.output + assert "--minikube" in result.output + + +class TestClusterDeletion: + """Test cluster deletion logic.""" + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.kind_installed") + @patch("jumpstarter_cli_admin.install.delete_kind_cluster") + async def test_delete_kind_cluster_success(self, mock_delete_kind, mock_kind_installed): + mock_kind_installed.return_value = True + mock_delete_kind.return_value = True + + await _delete_kind_cluster("kind", "test-cluster") + + mock_delete_kind.assert_called_once_with("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.kind_installed") + async def test_delete_kind_cluster_not_installed(self, mock_kind_installed): + mock_kind_installed.return_value = False + + with pytest.raises(click.ClickException, match="kind is not installed"): + await _delete_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.kind_installed") + @patch("jumpstarter_cli_admin.install.delete_kind_cluster") + async def test_delete_kind_cluster_failure(self, mock_delete_kind, mock_kind_installed): + mock_kind_installed.return_value = True + mock_delete_kind.side_effect = RuntimeError("Deletion failed") + + with pytest.raises(click.ClickException, match="Failed to delete Kind cluster"): + await _delete_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.minikube_installed") + @patch("jumpstarter_cli_admin.install.delete_minikube_cluster") + async def test_delete_minikube_cluster_success(self, mock_delete_minikube, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_delete_minikube.return_value = True + + await _delete_minikube_cluster("minikube", "test-cluster") + + mock_delete_minikube.assert_called_once_with("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.minikube_installed") + async def test_delete_minikube_cluster_not_installed(self, mock_minikube_installed): + mock_minikube_installed.return_value = False + + with pytest.raises(click.ClickException, match="minikube is not installed"): + await _delete_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.minikube_installed") + @patch("jumpstarter_cli_admin.install.delete_minikube_cluster") + async def test_delete_minikube_cluster_failure(self, mock_delete_minikube, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_delete_minikube.side_effect = RuntimeError("Deletion failed") + + with pytest.raises(click.ClickException, match="Failed to delete Minikube cluster"): + await _delete_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + async def test_handle_cluster_deletion_no_cluster_type(self, mock_validate_cluster): + mock_validate_cluster.return_value = None + + # Should return early without doing anything + await _handle_cluster_deletion(None, None, "test-cluster") + + mock_validate_cluster.assert_called_once_with(None, None) + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install.click.confirm") + @patch("jumpstarter_cli_admin.install._delete_kind_cluster") + async def test_handle_cluster_deletion_kind_confirmed(self, mock_delete_kind, mock_confirm, mock_validate_cluster): + mock_validate_cluster.return_value = "kind" + mock_confirm.return_value = True + + await _handle_cluster_deletion("kind", None, "test-cluster") + + mock_confirm.assert_called_once() + mock_delete_kind.assert_called_once_with("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install.click.confirm") + async def test_handle_cluster_deletion_cancelled(self, mock_confirm, mock_validate_cluster): + mock_validate_cluster.return_value = "kind" + mock_confirm.return_value = False + + await _handle_cluster_deletion("kind", None, "test-cluster") + + mock_confirm.assert_called_once() + # No deletion should occur + + @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install._validate_cluster_type") + @patch("jumpstarter_cli_admin.install.click.confirm") + @patch("jumpstarter_cli_admin.install._delete_minikube_cluster") + async def test_handle_cluster_deletion_minikube_confirmed( + self, mock_delete_minikube, mock_confirm, mock_validate_cluster + ): + mock_validate_cluster.return_value = "minikube" + mock_confirm.return_value = True + + await _handle_cluster_deletion(None, "minikube", "test-cluster") + + mock_confirm.assert_called_once() + mock_delete_minikube.assert_called_once_with("minikube", "test-cluster") + + +class TestUninstallCommand: + """Test the main uninstall CLI command.""" + + def setup_method(self): + self.runner = CliRunner() + + @patch("jumpstarter_cli_admin.install.helm_installed") + def test_uninstall_command_helm_not_installed(self, mock_helm_installed): + mock_helm_installed.return_value = False + + result = self.runner.invoke(uninstall, []) + + assert result.exit_code != 0 + assert "helm is not installed" in result.output + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install.uninstall_helm_chart") + def test_uninstall_command_success_minimal(self, mock_uninstall_helm, mock_helm_installed): + mock_helm_installed.return_value = True + mock_uninstall_helm.return_value = None + + result = self.runner.invoke(uninstall, []) + + assert result.exit_code == 0 + mock_uninstall_helm.assert_called_once_with("jumpstarter", "jumpstarter-lab", None, None, "helm") + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install.uninstall_helm_chart") + @patch("jumpstarter_cli_admin.install._handle_cluster_deletion") + def test_uninstall_command_with_cluster_deletion( + self, mock_handle_deletion, mock_uninstall_helm, mock_helm_installed + ): + mock_helm_installed.return_value = True + mock_uninstall_helm.return_value = None + + result = self.runner.invoke(uninstall, ["--delete-cluster", "--kind", "kind"]) + + assert result.exit_code == 0 + mock_uninstall_helm.assert_called_once() + mock_handle_deletion.assert_called_once_with("kind", None, "jumpstarter-lab") + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install.uninstall_helm_chart") + @patch("jumpstarter_cli_admin.install._handle_cluster_deletion") + def test_uninstall_command_with_custom_options( + self, mock_handle_deletion, mock_uninstall_helm, mock_helm_installed + ): + mock_helm_installed.return_value = True + mock_uninstall_helm.return_value = None + + result = self.runner.invoke( + uninstall, + [ + "--helm", + "custom-helm", + "--name", + "custom-name", + "--namespace", + "custom-namespace", + "--delete-cluster", + "--minikube", + "custom-minikube", + "--cluster-name", + "custom-cluster", + ], + ) + + assert result.exit_code == 0 + mock_uninstall_helm.assert_called_once_with("custom-name", "custom-namespace", None, None, "custom-helm") + mock_handle_deletion.assert_called_once_with(None, "custom-minikube", "custom-cluster") + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install.uninstall_helm_chart") + def test_uninstall_command_helm_failure(self, mock_uninstall_helm, mock_helm_installed): + mock_helm_installed.return_value = True + mock_uninstall_helm.side_effect = RuntimeError("Uninstall failed") + + result = self.runner.invoke(uninstall, []) + + assert result.exit_code != 0 + assert result.exception # Should have an exception + + @patch("jumpstarter_cli_admin.install.helm_installed") + @patch("jumpstarter_cli_admin.install.uninstall_helm_chart") + @patch("jumpstarter_cli_admin.install._handle_cluster_deletion") + def test_uninstall_command_cluster_deletion_only( + self, mock_handle_deletion, mock_uninstall_helm, mock_helm_installed + ): + mock_helm_installed.return_value = True + mock_uninstall_helm.return_value = None + + result = self.runner.invoke(uninstall, ["--delete-cluster", "--kind", "kind", "--cluster-name", "test-cluster"]) + + assert result.exit_code == 0 + mock_handle_deletion.assert_called_once_with("kind", None, "test-cluster") + + def test_uninstall_command_help(self): + result = self.runner.invoke(uninstall, ["--help"]) + + assert result.exit_code == 0 + assert "Uninstall" in result.output or "Usage:" in result.output + assert "--delete-cluster" in result.output + assert "--kind" in result.output + assert "--minikube" in result.output diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py new file mode 100644 index 000000000..1e66497c3 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py @@ -0,0 +1,395 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from jumpstarter_kubernetes.cluster import ( + create_kind_cluster, + create_minikube_cluster, + delete_kind_cluster, + delete_minikube_cluster, + kind_cluster_exists, + kind_installed, + minikube_cluster_exists, + minikube_installed, + run_command, + run_command_with_output, +) + + +class TestClusterDetection: + """Test cluster tool detection functions.""" + + @patch("jumpstarter_kubernetes.cluster.shutil.which") + def test_kind_installed_true(self, mock_which): + mock_which.return_value = "/usr/local/bin/kind" + assert kind_installed("kind") is True + mock_which.assert_called_once_with("kind") + + @patch("jumpstarter_kubernetes.cluster.shutil.which") + def test_kind_installed_false(self, mock_which): + mock_which.return_value = None + assert kind_installed("kind") is False + mock_which.assert_called_once_with("kind") + + @patch("jumpstarter_kubernetes.cluster.shutil.which") + def test_minikube_installed_true(self, mock_which): + mock_which.return_value = "/usr/local/bin/minikube" + assert minikube_installed("minikube") is True + mock_which.assert_called_once_with("minikube") + + @patch("jumpstarter_kubernetes.cluster.shutil.which") + def test_minikube_installed_false(self, mock_which): + mock_which.return_value = None + assert minikube_installed("minikube") is False + mock_which.assert_called_once_with("minikube") + + +class TestCommandExecution: + """Test command execution utilities.""" + + @pytest.mark.asyncio + async def test_run_command_success(self): + with patch("asyncio.create_subprocess_exec") as mock_subprocess: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"output\n", b"") + mock_process.returncode = 0 + mock_subprocess.return_value = mock_process + + returncode, stdout, stderr = await run_command(["echo", "test"]) + + assert returncode == 0 + assert stdout == "output" + assert stderr == "" + mock_subprocess.assert_called_once_with( + "echo", "test", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + + @pytest.mark.asyncio + async def test_run_command_failure(self): + with patch("asyncio.create_subprocess_exec") as mock_subprocess: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"", b"error message\n") + mock_process.returncode = 1 + mock_subprocess.return_value = mock_process + + returncode, stdout, stderr = await run_command(["false"]) + + assert returncode == 1 + assert stdout == "" + assert stderr == "error message" + + @pytest.mark.asyncio + async def test_run_command_not_found(self): + with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")): + with pytest.raises(RuntimeError, match="Command not found: nonexistent"): + await run_command(["nonexistent"]) + + @pytest.mark.asyncio + async def test_run_command_with_output_success(self): + with patch("asyncio.create_subprocess_exec") as mock_subprocess: + mock_process = AsyncMock() + mock_process.wait.return_value = 0 + mock_subprocess.return_value = mock_process + + returncode = await run_command_with_output(["echo", "test"]) + + assert returncode == 0 + mock_subprocess.assert_called_once_with("echo", "test") + + @pytest.mark.asyncio + async def test_run_command_with_output_not_found(self): + with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")): + with pytest.raises(RuntimeError, match="Command not found: nonexistent"): + await run_command_with_output(["nonexistent"]) + + +class TestClusterExistence: + """Test cluster existence checking functions.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.run_command") + async def test_kind_cluster_exists_true(self, mock_run_command, mock_kind_installed): + mock_kind_installed.return_value = True + mock_run_command.return_value = (0, "", "") + + result = await kind_cluster_exists("kind", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["kind", "get", "kubeconfig", "--name", "test-cluster"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.run_command") + async def test_kind_cluster_exists_false(self, mock_run_command, mock_kind_installed): + mock_kind_installed.return_value = True + mock_run_command.return_value = (1, "", "cluster not found") + + result = await kind_cluster_exists("kind", "test-cluster") + + assert result is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + async def test_kind_cluster_exists_kind_not_installed(self, mock_kind_installed): + mock_kind_installed.return_value = False + + result = await kind_cluster_exists("kind", "test-cluster") + + assert result is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.run_command") + async def test_kind_cluster_exists_runtime_error(self, mock_run_command, mock_kind_installed): + mock_kind_installed.return_value = True + mock_run_command.side_effect = RuntimeError("Command failed") + + result = await kind_cluster_exists("kind", "test-cluster") + + assert result is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.run_command") + async def test_minikube_cluster_exists_true(self, mock_run_command, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_run_command.return_value = (0, "", "") + + result = await minikube_cluster_exists("minikube", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["minikube", "status", "-p", "test-cluster"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + async def test_minikube_cluster_exists_minikube_not_installed(self, mock_minikube_installed): + mock_minikube_installed.return_value = False + + result = await minikube_cluster_exists("minikube", "test-cluster") + + assert result is False + + +class TestKindClusterOperations: + """Test Kind cluster creation and deletion.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists") + @patch("asyncio.create_subprocess_exec") + async def test_create_kind_cluster_success(self, mock_subprocess, mock_cluster_exists, mock_kind_installed): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + + mock_process = AsyncMock() + mock_process.returncode = 0 + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + result = await create_kind_cluster("kind", "test-cluster") + + assert result is True + mock_subprocess.assert_called_once() + args, kwargs = mock_subprocess.call_args + assert args[0] == "kind" + assert args[1] == "create" + assert args[2] == "cluster" + assert "--name" in args + assert "test-cluster" in args + assert kwargs["stdin"] == asyncio.subprocess.PIPE + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + async def test_create_kind_cluster_not_installed(self, mock_kind_installed): + mock_kind_installed.return_value = False + + with pytest.raises(RuntimeError, match="kind is not installed"): + await create_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists") + async def test_create_kind_cluster_already_exists(self, mock_cluster_exists, mock_kind_installed): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = True + + with pytest.raises(RuntimeError, match="Kind cluster 'test-cluster' already exists"): + await create_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.delete_kind_cluster") + @patch("asyncio.create_subprocess_exec") + async def test_create_kind_cluster_force_recreate( + self, mock_subprocess, mock_delete, mock_cluster_exists, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = True + mock_delete.return_value = True + + mock_process = AsyncMock() + mock_process.returncode = 0 + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + result = await create_kind_cluster("kind", "test-cluster", force_recreate=True) + + assert result is True + mock_delete.assert_called_once_with("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists") + @patch("asyncio.create_subprocess_exec") + async def test_create_kind_cluster_with_extra_args(self, mock_subprocess, mock_cluster_exists, mock_kind_installed): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + + mock_process = AsyncMock() + mock_process.returncode = 0 + mock_process.communicate.return_value = (b"", b"") + mock_subprocess.return_value = mock_process + + result = await create_kind_cluster("kind", "test-cluster", extra_args=["--verbosity=1"]) + + assert result is True + args, _ = mock_subprocess.call_args + assert "--verbosity=1" in args + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.run_command_with_output") + async def test_delete_kind_cluster_success(self, mock_run_command, mock_cluster_exists, mock_kind_installed): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = True + mock_run_command.return_value = 0 + + result = await delete_kind_cluster("kind", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["kind", "delete", "cluster", "--name", "test-cluster"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + async def test_delete_kind_cluster_not_installed(self, mock_kind_installed): + mock_kind_installed.return_value = False + + with pytest.raises(RuntimeError, match="kind is not installed"): + await delete_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists") + async def test_delete_kind_cluster_already_deleted(self, mock_cluster_exists, mock_kind_installed): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + + result = await delete_kind_cluster("kind", "test-cluster") + + assert result is True # Already deleted, consider successful + + +class TestMinikubeClusterOperations: + """Test Minikube cluster creation and deletion.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.run_command_with_output") + async def test_create_minikube_cluster_success( + self, mock_run_command, mock_cluster_exists, mock_minikube_installed + ): + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 0 + + result = await create_minikube_cluster("minikube", "test-cluster") + + assert result is True + mock_run_command.assert_called_once() + args = mock_run_command.call_args[0][0] + assert args[0] == "minikube" + assert args[1] == "start" + assert "--profile" in args + assert "test-cluster" in args + assert "--extra-config=apiserver.service-node-port-range=8000-9000" in args + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + async def test_create_minikube_cluster_not_installed(self, mock_minikube_installed): + mock_minikube_installed.return_value = False + + with pytest.raises(RuntimeError, match="minikube is not installed"): + await create_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists") + async def test_create_minikube_cluster_already_exists(self, mock_cluster_exists, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = True + + with pytest.raises(RuntimeError, match="Minikube cluster 'test-cluster' already exists"): + await create_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.run_command_with_output") + async def test_create_minikube_cluster_with_extra_args( + self, mock_run_command, mock_cluster_exists, mock_minikube_installed + ): + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 0 + + result = await create_minikube_cluster("minikube", "test-cluster", extra_args=["--memory=4096"]) + + assert result is True + args = mock_run_command.call_args[0][0] + assert "--memory=4096" in args + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.run_command_with_output") + async def test_delete_minikube_cluster_success( + self, mock_run_command, mock_cluster_exists, mock_minikube_installed + ): + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = True + mock_run_command.return_value = 0 + + result = await delete_minikube_cluster("minikube", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["minikube", "delete", "-p", "test-cluster"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists") + async def test_delete_minikube_cluster_already_deleted(self, mock_cluster_exists, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = False + + result = await delete_minikube_cluster("minikube", "test-cluster") + + assert result is True # Already deleted, consider successful + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.run_command_with_output") + async def test_delete_minikube_cluster_failure( + self, mock_run_command, mock_cluster_exists, mock_minikube_installed + ): + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = True + mock_run_command.return_value = 1 + + with pytest.raises(RuntimeError, match="Failed to delete Minikube cluster 'test-cluster'"): + await delete_minikube_cluster("minikube", "test-cluster") diff --git a/packages/jumpstarter/jumpstarter/common/ipaddr_test.py b/packages/jumpstarter/jumpstarter/common/ipaddr_test.py new file mode 100644 index 000000000..7b07c6688 --- /dev/null +++ b/packages/jumpstarter/jumpstarter/common/ipaddr_test.py @@ -0,0 +1,77 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from jumpstarter.common.ipaddr import get_minikube_ip + + +class TestIPAddressDetection: + """Test IP address detection functions.""" + + @pytest.mark.asyncio + @patch("asyncio.create_subprocess_exec") + async def test_get_minikube_ip_success(self, mock_subprocess): + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"192.168.49.2\n", b"") + mock_process.returncode = 0 + mock_subprocess.return_value = mock_process + + result = await get_minikube_ip() + + assert result == "192.168.49.2" + mock_subprocess.assert_called_once_with( + "minikube", + "ip", + stdout=-1, + stderr=-1, # asyncio.subprocess.PIPE constants + ) + + @pytest.mark.asyncio + @patch("asyncio.create_subprocess_exec") + async def test_get_minikube_ip_with_profile(self, mock_subprocess): + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"192.168.49.3\n", b"") + mock_process.returncode = 0 + mock_subprocess.return_value = mock_process + + result = await get_minikube_ip("test-profile") + + assert result == "192.168.49.3" + mock_subprocess.assert_called_once_with("minikube", "ip", "-p", "test-profile", stdout=-1, stderr=-1) + + @pytest.mark.asyncio + @patch("asyncio.create_subprocess_exec") + async def test_get_minikube_ip_custom_binary(self, mock_subprocess): + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"10.0.0.5\n", b"") + mock_process.returncode = 0 + mock_subprocess.return_value = mock_process + + result = await get_minikube_ip(minikube="custom-minikube") + + assert result == "10.0.0.5" + mock_subprocess.assert_called_once_with("custom-minikube", "ip", stdout=-1, stderr=-1) + + @pytest.mark.asyncio + @patch("asyncio.create_subprocess_exec") + async def test_get_minikube_ip_failure(self, mock_subprocess): + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"", b"error: cluster not found\n") + mock_process.returncode = 1 + mock_subprocess.return_value = mock_process + + with pytest.raises(RuntimeError, match="error: cluster not found"): + await get_minikube_ip() + + @pytest.mark.asyncio + @patch("asyncio.create_subprocess_exec") + async def test_get_minikube_ip_profile_and_custom_binary(self, mock_subprocess): + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"172.16.0.1\n", b"") + mock_process.returncode = 0 + mock_subprocess.return_value = mock_process + + result = await get_minikube_ip("my-profile", "my-minikube") + + assert result == "172.16.0.1" + mock_subprocess.assert_called_once_with("my-minikube", "ip", "-p", "my-profile", stdout=-1, stderr=-1) From 7673fbfe4926a1216283c0d83267752a79206f18 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:37:42 -0400 Subject: [PATCH 06/11] Fix minikube installation detection in tests --- .../jumpstarter_cli_admin/install_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py index d700935ee..b75c77fd8 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py @@ -250,9 +250,11 @@ class TestIPDetection: """Test IP address detection functions.""" @pytest.mark.asyncio + @patch("jumpstarter_cli_admin.install.minikube_installed") @patch("jumpstarter_cli_admin.install.get_minikube_ip") @patch("jumpstarter_cli_admin.install.get_ip_address") - async def test_get_ip_generic_minikube(self, mock_get_ip_address, mock_get_minikube_ip): + async def test_get_ip_generic_minikube(self, mock_get_ip_address, mock_get_minikube_ip, mock_minikube_installed): + mock_minikube_installed.return_value = True mock_get_minikube_ip.return_value = "192.168.49.2" result = await get_ip_generic("minikube", "minikube", "test-cluster") From 978c2c85e01bc53e60c6b1aa025bca1e51575391 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:39:35 -0400 Subject: [PATCH 07/11] Fix duplicated argument in docs --- .../source/getting-started/installation/service/service-local.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/getting-started/installation/service/service-local.md b/docs/source/getting-started/installation/service/service-local.md index 6a6bba339..dc388904a 100644 --- a/docs/source/getting-started/installation/service/service-local.md +++ b/docs/source/getting-started/installation/service/service-local.md @@ -53,7 +53,6 @@ Additional options for cluster creation: - `--force-recreate-cluster`: Force recreate the cluster if it already exists (destroys all data) - `--kind-extra-args`: Pass additional arguments to kind cluster creation - `--minikube-extra-args`: Pass additional arguments to minikube cluster creation -- `--kind-extra-args`: Pass additional arguments to kind cluster creation To set a custom cluster name: From 655b9e0b260cf115bd1902e4838cb244e5d73ea2 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 22 Jun 2025 17:42:29 -0400 Subject: [PATCH 08/11] Fix cluster name argument --- packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py index 8521215d6..042aa9871 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py @@ -215,7 +215,7 @@ async def _install_jumpstarter_helm_chart( click.echo(f'Installed Helm release "{name}" in namespace "{namespace}"') -async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_name: str = None) -> str: +async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_name: str) -> str: if cluster_type == "minikube": if not minikube_installed(minikube): raise click.ClickException("minikube is not installed (or not in your PATH)") From 23b38697b62797af127b2615167a91d7d80908e7 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 2 Jul 2025 04:24:48 -0400 Subject: [PATCH 09/11] Update service-production.md Co-authored-by: Miguel Angel Ajo Pelayo --- .../getting-started/installation/service/service-production.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/source/getting-started/installation/service/service-production.md b/docs/source/getting-started/installation/service/service-production.md index c65af3a4c..ef3cd75be 100644 --- a/docs/source/getting-started/installation/service/service-production.md +++ b/docs/source/getting-started/installation/service/service-production.md @@ -12,8 +12,6 @@ Before installing in production, ensure you have: - Administrator access to your cluster (required for CRD installation) - Domain name for service endpoints - Ingress controller installed (for Kubernetes) or Routes configured (for OpenShift) -- TLS certificates for your domain (or cert-manager for automatic certificate management) - ```{note} `global.baseDomain` creates these service hostnames with `jumpstarter.example.com`: - `grpc.jumpstarter.example.com` From 45efebd79a06f9c86752a5b1852c41965387e680 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 2 Jul 2025 04:24:56 -0400 Subject: [PATCH 10/11] Update service-production.md Co-authored-by: Miguel Angel Ajo Pelayo --- .../service/service-production.md | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/docs/source/getting-started/installation/service/service-production.md b/docs/source/getting-started/installation/service/service-production.md index ef3cd75be..0fb396a23 100644 --- a/docs/source/getting-started/installation/service/service-production.md +++ b/docs/source/getting-started/installation/service/service-production.md @@ -47,53 +47,6 @@ Choose one of these TLS termination approaches: gRPC over HTTP/1.1 is not supported. Ensure your ingress controller supports HTTP/2 and is properly configured for gRPC traffic. ``` -### Ingress Controller Configuration Examples - -Different ingress controllers require specific annotations for gRPC traffic: - -**NGINX Ingress Controller:** -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - nginx.ingress.kubernetes.io/backend-protocol: "GRPC" - nginx.ingress.kubernetes.io/grpc-backend: "true" - cert-manager.io/cluster-issuer: "letsencrypt-prod" # If using cert-manager -``` - -**AWS Load Balancer Controller (ALB):** -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - kubernetes.io/ingress.class: alb - alb.ingress.kubernetes.io/scheme: internet-facing - alb.ingress.kubernetes.io/target-type: ip - alb.ingress.kubernetes.io/backend-protocol-version: GRPC - alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' - alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:region:account:certificate/cert-id -``` - -**Istio Gateway:** -```yaml -apiVersion: networking.istio.io/v1beta1 -kind: Gateway -metadata: - name: jumpstarter-gateway -spec: - servers: - - port: - number: 443 - name: https-grpc - protocol: HTTPS - tls: - mode: SIMPLE - credentialName: jumpstarter-tls-secret -``` - ## Install with Helm Install Jumpstarter on a Kubernetes/OpenShift cluster using Helm: From 4d3e1e086fb2571753ebf4e05307df366a2a1aa3 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sat, 5 Jul 2025 15:38:31 -0400 Subject: [PATCH 11/11] Update docs/source/getting-started/installation/service/service-local.md Co-authored-by: Miguel Angel Ajo Pelayo --- .../getting-started/installation/service/service-local.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/getting-started/installation/service/service-local.md b/docs/source/getting-started/installation/service/service-local.md index dc388904a..03ad1dd66 100644 --- a/docs/source/getting-started/installation/service/service-local.md +++ b/docs/source/getting-started/installation/service/service-local.md @@ -1,6 +1,6 @@ # Local Installation -For local development and testing, you can install Jumpstarter on local Kubernetes clusters using tools like kind or minikube. This is ideal for getting started quickly or for CI/CD pipelines. +For local development and testing, you can install Jumpstarter on local Kubernetes clusters using tools like kind or minikube. This is ideal for learning about the distributed service quickly or for creating CI/CD pipelines to validate your own Jumpstarter drivers. ## Prerequisites