diff --git a/docs/source/conf.py b/docs/source/conf.py index 4674a0ba4..e70aaca01 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,7 +10,7 @@ import os import sys -from jumpstarter_cli_admin.controller import get_latest_compatible_controller_version +from jumpstarter_kubernetes.controller import get_latest_compatible_controller_version os.environ["TERM"] = "dumb" @@ -32,7 +32,7 @@ "sphinx_click", "sphinx_substitution_extensions", "sphinx_copybutton", - "sphinx_inline_tabs" + "sphinx_inline_tabs", ] templates_path = ["_templates"] diff --git a/docs/source/getting-started/installation/service/service-local.md b/docs/source/getting-started/installation/service/service-local.md index 03ad1dd66..5fb4dd428 100644 --- a/docs/source/getting-started/installation/service/service-local.md +++ b/docs/source/getting-started/installation/service/service-local.md @@ -13,9 +13,13 @@ Before installing locally, ensure you have: ## 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. +The Jumpstarter CLI provides convenient commands for local demo/test cluster management and Jumpstarter installation: + +- `jmp admin create cluster` - Creates a local cluster and installs Jumpstarter (recommended for getting started quickly) +- `jmp admin delete cluster` - Deletes a local cluster completely +- `jmp admin get clusters` - Get local clusters from a Kubeconfig +- `jmp admin install` - Installs Jumpstarter on an existing cluster +- `jmp admin uninstall` - Removes Jumpstarter from a cluster (but keeps the 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. @@ -27,44 +31,65 @@ If you want to test Jumpstarter locally with more control over the setup, you ca [**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. +[**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. Minikube works better if you don't have a local Docker/Podman installation. + +The admin CLI can automatically create a local cluster and install Jumpstarter with a single command: + +By default, Jumpstarter will try to detect which local cluster tools are installed: ```{tip} -Consider minikube for environments requiring [untrusted certificates](https://minikube.sigs.k8s.io/docs/handbook/untrusted_certs/). +By default, Jumpstarter will use `kind` if available, use the `--minikube` argument to force Jumpstarter to use minikube instead. ``` -The admin CLI can automatically create a local cluster and install Jumpstarter with a single command: +```{code-block} console +$ jmp admin create cluster +``` + +However, you can also explicitly specify a local cluster tool: ````{tab} kind ```{code-block} console -$ jmp admin install --kind --create-cluster +$ jmp admin create cluster --kind ``` + +Additional options for cluster creation: + +- Custom cluster name: Specify as the first argument (default: `jumpstarter-lab`) +- `--kind `: Path to the kind binary to use for cluster management +- `--helm `: Path to the Helm binary to install the Jumpstarter service with +- `--force-recreate`: Force recreate the cluster if it already exists (destroys all data) +- `--kind-extra-args`: Pass additional arguments to kind cluster creation +- `--skip-install`: Create the cluster without installing Jumpstarter +- `--extra-certs `: Path to custom CA certificate bundle file to inject into the cluster ```` ````{tab} minikube ```{code-block} console -$ jmp admin install --minikube --create-cluster +$ jmp admin create cluster --minikube ``` -```` 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 +- Custom cluster name: Specify as the first argument (default: `jumpstarter-lab`) +- `--minikube `: Path to the minikube binary to use for cluster management +- `--helm `: Path to the Helm binary to install the Jumpstarter service with +- `--force-recreate`: Force recreate the cluster if it already exists (destroys all data) - `--minikube-extra-args`: Pass additional arguments to minikube cluster creation +- `--skip-install`: Create the cluster without installing Jumpstarter +- `--extra-certs `: Path to custom CA certificate bundle file to inject into the cluster +```` To set a custom cluster name: ````{tab} kind ```{code-block} console -$ jmp admin install --kind --create-cluster --cluster-name my-jumpstarter-cluster +$ jmp admin create cluster my-jumpstarter-cluster --kind ``` ```` ````{tab} minikube ```{code-block} console -$ jmp admin install --minikube --create-cluster --cluster-name my-jumpstarter-cluster +$ jmp admin create cluster my-jumpstarter-cluster --minikube ``` ```` @@ -90,28 +115,41 @@ $ jmp admin install --minikube ### Uninstall Jumpstarter -Uninstall Jumpstarter with the CLI: +Uninstall Jumpstarter from the cluster with the CLI: ```{code-block} console $ jmp admin uninstall ``` -To delete the local cluster when uninstalling, use the `--delete-cluster` flag: +To delete the local cluster completely, use the cluster delete command: + +````{tab} kind +```{code-block} console +$ jmp admin delete cluster --kind +``` +```` + +````{tab} minikube +```{code-block} console +$ jmp admin delete cluster --minikube +``` +```` + +To delete a cluster with a custom name: ````{tab} kind ```{code-block} console -$ jmp admin uninstall --kind --delete-cluster +$ jmp admin delete cluster my-jumpstarter-cluster --kind ``` ```` ````{tab} minikube ```{code-block} console -$ jmp admin uninstall --minikube --delete-cluster +$ jmp admin delete cluster my-jumpstarter-cluster --minikube ``` ```` -For complete documentation of the `jmp admin install` command and all available -options, see the [MAN pages](../../../reference/man-pages/jmp.md). +For complete documentation of the `jmp admin create cluster`, `jmp admin delete cluster`, `jmp admin get clusters`, and `jmp admin install` commands and all available options, see the [MAN pages](../../../reference/man-pages/jmp.md). ## Manual Local Cluster Install diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/controller.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/controller.py deleted file mode 100644 index a16010efe..000000000 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/controller.py +++ /dev/null @@ -1,55 +0,0 @@ -import aiohttp -import click -import semver -from jumpstarter_cli_common.version import get_client_version -from packaging.version import Version - - -async def get_latest_compatible_controller_version( - client_version: str | None = None, -): - if client_version is None: - client_version = Version(get_client_version()) - else: - client_version = Version(client_version) - - async with aiohttp.ClientSession( - raise_for_status=True, - ) as session: - try: - async with session.get( - "https://quay.io/api/v1/repository/jumpstarter-dev/helm/jumpstarter/tag/", - timeout=aiohttp.ClientTimeout(total=30), - ) as resp: - resp = await resp.json() - except Exception as e: - raise click.ClickException(f"Failed to fetch controller versions: {e}") from e - - compatible = set() - fallback = set() - - if not isinstance(resp, dict) or "tags" not in resp or not isinstance(resp["tags"], list): - raise click.ClickException("Unexpected response fetching controller version") - - for tag in resp["tags"]: - if not isinstance(tag, dict) or "name" not in tag: - continue # Skip malformed tag entries - - try: - version = semver.VersionInfo.parse(tag["name"]) - except ValueError: - continue # ignore invalid versions - - if version.major == client_version.major and version.minor == client_version.minor: - compatible.add(version) - else: - fallback.add(version) - - if compatible: - selected = max(compatible) - elif fallback: - selected = max(fallback) - else: - raise ValueError("No valid controller versions found in the repository") - - return str(selected) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 2234376d5..295c527ee 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -3,6 +3,7 @@ import click from jumpstarter_cli_common.alias import AliasedGroup from jumpstarter_cli_common.blocking import blocking +from jumpstarter_cli_common.callbacks import ClickCallback from jumpstarter_cli_common.opt import ( OutputType, confirm_insecure_tls, @@ -15,7 +16,13 @@ opt_output_all, ) from jumpstarter_cli_common.print import model_print -from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api +from jumpstarter_kubernetes import ( + ClientsV1Alpha1Api, + ExportersV1Alpha1Api, + create_cluster_and_install, + validate_cluster_type_selection, +) +from jumpstarter_kubernetes.exceptions import JumpstarterKubernetesError from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -178,3 +185,141 @@ async def create_exporter( handle_k8s_api_exception(e) except ConfigException as e: handle_k8s_config_exception(e) + + +@create.command("cluster") +@click.argument("name", type=str, required=False, default="jumpstarter-lab") +@click.option("--kind", is_flag=False, flag_value="kind", default=None, help="Create a local Kind cluster") +@click.option( + "--minikube", + is_flag=False, + flag_value="minikube", + default=None, + help="Create a local Minikube cluster", +) +@click.option( + "--force-recreate", + is_flag=True, + help="Force recreate the cluster if it already exists (WARNING: This will destroy all data in the cluster)", +) +@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( + "--extra-certs", + type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True), + help="Path to custom CA certificate bundle file to inject into the cluster", +) +@click.option( + "--skip-install", + is_flag=True, + help="Skip installing Jumpstarter after creating the cluster", +) +@click.option("--helm", type=str, help="Path or name of a helm executable", default="helm") +@click.option( + "--chart", + type=str, + help="The URL of a Jumpstarter helm chart to install", + default="oci://quay.io/jumpstarter-dev/helm/jumpstarter", +) +@click.option("--chart-name", type=str, help="The name of the chart installation", default="jumpstarter") +@click.option( + "-n", "--namespace", type=str, help="Namespace to install Jumpstarter components in", default="jumpstarter-lab" +) +@click.option("-i", "--ip", type=str, help="IP address of your host machine", default=None) +@click.option("-b", "--basedomain", type=str, help="Base domain of the Jumpstarter service", default=None) +@click.option("-g", "--grpc-endpoint", type=str, help="The gRPC endpoint to use for the Jumpstarter API", default=None) +@click.option("-r", "--router-endpoint", type=str, help="The gRPC endpoint to use for the router", default=None) +@click.option("-v", "--version", help="The version of the service to install", default=None) +@click.option( + "-f", + "--values-file", + "values_files", + type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True), + multiple=True, + help="Path to custom helm values file (can be specified multiple times)", +) +@opt_kubeconfig +@opt_context +@opt_nointeractive +@opt_output_all +@blocking +async def create_cluster( + name: str, + kind: Optional[str], + minikube: Optional[str], + force_recreate: bool, + kind_extra_args: str, + minikube_extra_args: str, + extra_certs: Optional[str], + skip_install: bool, + helm: str, + chart: str, + chart_name: str, + namespace: str, + ip: Optional[str], + basedomain: Optional[str], + grpc_endpoint: Optional[str], + router_endpoint: Optional[str], + version: Optional[str], + values_files: tuple[str, ...], + kubeconfig: Optional[str], + context: Optional[str], + nointeractive: bool, + output: OutputType, +): + """Create a Kubernetes cluster for running Jumpstarter""" + cluster_type = validate_cluster_type_selection(kind, minikube) + + if output is None: + if kind is None and minikube is None: + click.echo(f"Auto-detected {cluster_type} as the cluster type") + if skip_install: + click.echo(f'Creating {cluster_type} cluster "{name}"...') + else: + click.echo(f'Creating {cluster_type} cluster "{name}" and installing Jumpstarter...') + + # Auto-detect version if not specified and installing Jumpstarter + if not skip_install and version is None: + from jumpstarter_cli_common.version import get_client_version + from jumpstarter_kubernetes import get_latest_compatible_controller_version + + version = await get_latest_compatible_controller_version(get_client_version()) + + # Create callback for library functions + # Use silent mode when JSON/YAML output is requested + callback = ClickCallback(silent=(output is not None)) + + try: + await create_cluster_and_install( + cluster_type, + force_recreate, + name, + kind_extra_args, + minikube_extra_args, + kind or "kind", + minikube or "minikube", + extra_certs, + install_jumpstarter=not skip_install, + helm=helm, + chart=chart, + chart_name=chart_name, + namespace=namespace, + version=version, + kubeconfig=kubeconfig, + context=context, + ip=ip, + basedomain=basedomain, + grpc_endpoint=grpc_endpoint, + router_endpoint=router_endpoint, + callback=callback, + values_files=list(values_files) if values_files else None, + ) + except JumpstarterKubernetesError as e: + # Convert library exceptions to CLI exceptions + raise click.ClickException(str(e)) from e + + if output is None: + if skip_install: + click.echo(f'Cluster "{name}" is ready for Jumpstarter installation.') + else: + click.echo(f'Cluster "{name}" created and Jumpstarter installed successfully!') diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index c0bc5525a..7331c3b5b 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py @@ -2,6 +2,7 @@ from pathlib import Path from unittest.mock import AsyncMock, Mock, patch +import click from click.testing import CliRunner from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, @@ -371,3 +372,284 @@ def test_create_exporter( assert result.output == f"exporter.jumpstarter.dev/{EXPORTER_NAME}\n" save_exporter_mock.assert_not_called() save_exporter_mock.reset_mock() + + +class TestClusterCreation: + """Test cluster creation commands.""" + + def setup_method(self): + self.runner = CliRunner() + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_kind_minimal(self, mock_validate, mock_create): + """Test creating a Kind cluster with minimal options""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code == 0 + assert "Creating kind cluster" in result.output + mock_validate.assert_called_once_with("kind", None) + mock_create.assert_called_once() + + # Verify the arguments passed to create_cluster_and_install + args, kwargs = mock_create.call_args + assert args[0] == "kind" # cluster_type + assert args[1] is False # force_recreate_cluster + assert args[2] == "test-cluster" # cluster_name + assert args[3] == "" # kind_extra_args + assert args[4] == "" # minikube_extra_args + assert args[5] == "kind" # kind binary + assert args[6] == "minikube" # minikube binary + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_minikube_minimal(self, mock_validate, mock_create): + """Test creating a Minikube cluster with minimal options""" + mock_validate.return_value = "minikube" + mock_create.return_value = None + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--minikube", "minikube"]) + + assert result.exit_code == 0 + assert "Creating minikube cluster" in result.output + mock_validate.assert_called_once_with(None, "minikube") + mock_create.assert_called_once() + + # Verify the arguments passed to create_cluster_and_install + args, kwargs = mock_create.call_args + assert args[0] == "minikube" # cluster_type + assert args[1] is False # force_recreate_cluster + assert args[2] == "test-cluster" # cluster_name + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_auto_detect(self, mock_validate, mock_create): + """Test auto-detection of cluster type when neither --kind nor --minikube is specified""" + mock_validate.return_value = "kind" # Auto-detected as kind + mock_create.return_value = None + + result = self.runner.invoke(create, ["cluster", "test-cluster"]) + + assert result.exit_code == 0 + assert "Auto-detected kind as the cluster type" in result.output + mock_validate.assert_called_once_with(None, None) + mock_create.assert_called_once() + + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_both_types_error(self, mock_validate): + """Test that specifying both --kind and --minikube raises an error""" + mock_validate.side_effect = click.ClickException("You can only select one local cluster type") + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind", "--minikube", "minikube"]) + + assert result.exit_code != 0 + assert "You can only select one local cluster type" in result.output + mock_validate.assert_called_once_with("kind", "minikube") + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_with_jumpstarter_installation(self, mock_validate, mock_create): + """Test creating cluster and installing Jumpstarter (default behavior)""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code == 0 + assert "and installing Jumpstarter" in result.output + assert "created and Jumpstarter installed successfully" in result.output + mock_create.assert_called_once() + + # Verify install_jumpstarter is True by default + kwargs = mock_create.call_args[1] + assert kwargs.get("install_jumpstarter", True) is True + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_skip_install(self, mock_validate, mock_create): + """Test creating cluster with --skip-install flag""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind", "--skip-install"]) + + assert result.exit_code == 0 + assert "Creating kind cluster" in result.output + assert "is ready for Jumpstarter installation" in result.output + mock_create.assert_called_once() + + # Verify install_jumpstarter is False + kwargs = mock_create.call_args[1] + assert kwargs.get("install_jumpstarter", True) is False + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_with_custom_chart(self, mock_validate, mock_create): + """Test with custom chart and version options""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + result = self.runner.invoke(create, [ + "cluster", "test-cluster", "--kind", "kind", + "--chart", "oci://custom.registry/helm/chart", + "--chart-name", "custom-jumpstarter", + "--version", "v1.2.3" + ]) + + assert result.exit_code == 0 + mock_create.assert_called_once() + + # Verify custom chart options + kwargs = mock_create.call_args[1] + assert kwargs.get("chart") == "oci://custom.registry/helm/chart" + assert kwargs.get("chart_name") == "custom-jumpstarter" + assert kwargs.get("version") == "v1.2.3" + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_with_custom_endpoints(self, mock_validate, mock_create): + """Test with custom IP, basedomain, and endpoints""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + result = self.runner.invoke(create, [ + "cluster", "test-cluster", "--kind", "kind", + "--ip", "192.168.1.100", + "--basedomain", "custom.example.com", + "--grpc-endpoint", "grpc.custom.example.com:9000", + "--router-endpoint", "router.custom.example.com:9001" + ]) + + assert result.exit_code == 0 + mock_create.assert_called_once() + + # Verify custom endpoint options + kwargs = mock_create.call_args[1] + assert kwargs.get("ip") == "192.168.1.100" + assert kwargs.get("basedomain") == "custom.example.com" + assert kwargs.get("grpc_endpoint") == "grpc.custom.example.com:9000" + assert kwargs.get("router_endpoint") == "router.custom.example.com:9001" + + @patch("jumpstarter_cli_admin.create.click.confirm") + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_force_recreate_confirmed(self, mock_validate, mock_create, mock_confirm): + """Test force recreate with user confirmation""" + mock_validate.return_value = "kind" + mock_create.return_value = None + mock_confirm.return_value = True + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind", "--force-recreate"]) + + assert result.exit_code == 0 + mock_create.assert_called_once() + + # Verify force_recreate_cluster is True + args = mock_create.call_args[0] + assert args[1] is True # force_recreate_cluster + + @patch("jumpstarter_cli_admin.create.click.confirm") + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_force_recreate_cancelled(self, mock_validate, mock_create, mock_confirm): + """Test force recreate when user cancels""" + mock_validate.return_value = "kind" + mock_create.side_effect = click.Abort() + mock_confirm.return_value = False + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind", "--force-recreate"]) + + assert result.exit_code != 0 + # Note: create_cluster_and_install itself handles the confirmation + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_with_extra_args(self, mock_validate, mock_create): + """Test with extra Kind/Minikube arguments""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + result = self.runner.invoke(create, [ + "cluster", "test-cluster", "--kind", "kind", + "--kind-extra-args", "--verbosity=1 --retain", + "--minikube-extra-args", "--memory=4096" + ]) + + assert result.exit_code == 0 + mock_create.assert_called_once() + + # Verify extra args + args = mock_create.call_args[0] + assert args[3] == "--verbosity=1 --retain" # kind_extra_args + assert args[4] == "--memory=4096" # minikube_extra_args + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_with_extra_certs(self, mock_validate, mock_create): + """Test with custom CA certificates""" + mock_validate.return_value = "kind" + mock_create.return_value = None + + # Create a temporary cert file for the test + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.crt', delete=False) as f: + f.write("dummy cert content") + cert_path = f.name + + try: + result = self.runner.invoke(create, [ + "cluster", "test-cluster", "--kind", "kind", + "--extra-certs", cert_path + ]) + + assert result.exit_code == 0 + mock_create.assert_called_once() + + # Verify extra_certs parameter (it's positional arg 7, index 7) + # Note: Click resolves the path, so we need to check the resolved version + args = mock_create.call_args[0] + import os + assert args[7] == os.path.realpath(cert_path) # extra_certs + finally: + # Clean up + import os + os.unlink(cert_path) + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_helm_not_installed(self, mock_validate, mock_create): + """Test error when helm is not installed (for installation)""" + mock_validate.return_value = "kind" + mock_create.side_effect = click.ClickException("helm is not installed") + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code != 0 + assert "helm is not installed" in result.output + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_kind_not_installed(self, mock_validate, mock_create): + """Test error when kind is not installed""" + mock_validate.return_value = "kind" + mock_create.side_effect = click.ClickException("kind is not installed") + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code != 0 + assert "kind is not installed" in result.output + + @patch("jumpstarter_cli_admin.create.create_cluster_and_install") + @patch("jumpstarter_cli_admin.create.validate_cluster_type_selection") + def test_create_cluster_minikube_not_installed(self, mock_validate, mock_create): + """Test error when minikube is not installed""" + mock_validate.return_value = "minikube" + mock_create.side_effect = click.ClickException("minikube is not installed") + + result = self.runner.invoke(create, ["cluster", "test-cluster", "--minikube", "minikube"]) + + assert result.exit_code != 0 + assert "minikube is not installed" in result.output diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py index a49268010..f18266633 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py @@ -3,6 +3,7 @@ import click from jumpstarter_cli_common.alias import AliasedGroup from jumpstarter_cli_common.blocking import blocking +from jumpstarter_cli_common.callbacks import ClickCallback, ForceClickCallback, SilentWithConfirmCallback from jumpstarter_cli_common.opt import ( NameOutputType, opt_context, @@ -11,7 +12,8 @@ opt_nointeractive, opt_output_name_only, ) -from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api +from jumpstarter_kubernetes import ClientsV1Alpha1Api, ExportersV1Alpha1Api, delete_cluster_by_name +from jumpstarter_kubernetes.exceptions import JumpstarterKubernetesError from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -124,3 +126,54 @@ async def delete_exporter( handle_k8s_api_exception(e) except ConfigException as e: handle_k8s_config_exception(e) + + +@delete.command("cluster") +@click.argument("name", type=str, required=False, default="jumpstarter-lab") +@click.option("--kind", is_flag=False, flag_value="kind", default=None, help="Delete a local Kind cluster") +@click.option( + "--minikube", + is_flag=False, + flag_value="minikube", + default=None, + help="Delete a local Minikube cluster", +) +@click.option( + "--force", + is_flag=True, + help="Skip confirmation prompt and force deletion", +) +@opt_output_name_only +@blocking +async def delete_cluster( + name: str, + kind: Optional[str], + minikube: Optional[str], + force: bool, + output: NameOutputType, +): + """Delete a Kubernetes cluster (auto-detects Kind or Minikube)""" + + # Determine cluster type from options + cluster_type = None + if kind is not None: + cluster_type = "kind" + elif minikube is not None: + cluster_type = "minikube" + + # Create appropriate callback based on output mode and force flag + if output is not None: + # For --output=name, use silent callback that still prompts for confirmation + callback = ForceClickCallback(silent=True) if force else SilentWithConfirmCallback() + else: + # For normal output, use regular callbacks + callback = ForceClickCallback(silent=False) if force else ClickCallback(silent=False) + + try: + await delete_cluster_by_name(name, cluster_type, force, callback) + if output is not None: + # For name-only output, just print the cluster name + click.echo(name) + except JumpstarterKubernetesError as e: + # Convert library exceptions to CLI exceptions + raise click.ClickException(str(e)) from e diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py index 73e31119d..18c97db8d 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py @@ -1,5 +1,6 @@ -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch +import click from click.testing import CliRunner from jumpstarter_kubernetes import ( ClientsV1Alpha1Api, @@ -248,3 +249,213 @@ def test_delete_exporter( mock_config_exists.reset_mock() mock_delete_exporter.reset_mock() mock_config_delete.reset_mock() + + +class TestClusterDeletion: + """Test cluster deletion commands.""" + + def setup_method(self): + self.runner = CliRunner() + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_kind_with_confirmation(self, mock_delete): + """Test deleting a Kind cluster with user confirmation""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_minikube_with_confirmation(self, mock_delete): + """Test deleting a Minikube cluster with user confirmation""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--minikube", "minikube"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", "minikube", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_auto_detect(self, mock_delete): + """Test auto-detection of cluster type when neither --kind nor --minikube is specified""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", None, False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_with_force(self, mock_delete): + """Test force deletion without confirmation prompt""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind", "--force"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", "kind", True, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_default_name(self, mock_delete): + """Test default cluster name is 'jumpstarter-lab'""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "--kind", "kind"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("jumpstarter-lab", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_confirmation_cancelled(self, mock_delete): + """Test when user cancels deletion confirmation""" + # Mock the delete function to raise Abort (user cancelled) + mock_delete.side_effect = click.Abort() + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code != 0 + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_force_skips_confirmation(self, mock_delete): + """Test that --force flag skips confirmation""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--minikube", "minikube", "--force"]) + + assert result.exit_code == 0 + # Verify force=True was passed + mock_delete.assert_called_once_with("test-cluster", "minikube", True, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_not_found(self, mock_delete): + """Test error when cluster doesn't exist""" + mock_delete.side_effect = click.ClickException('No cluster named "test-cluster" found') + + result = self.runner.invoke(delete, ["cluster", "test-cluster"]) + + assert result.exit_code != 0 + assert 'No cluster named "test-cluster" found' in result.output + mock_delete.assert_called_once_with("test-cluster", None, False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_kind_not_installed(self, mock_delete): + """Test error when kind is not installed""" + mock_delete.side_effect = click.ClickException("Kind is not installed") + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code != 0 + assert "Kind is not installed" in result.output + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_minikube_not_installed(self, mock_delete): + """Test error when minikube is not installed""" + mock_delete.side_effect = click.ClickException("Minikube is not installed") + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--minikube", "minikube"]) + + assert result.exit_code != 0 + assert "Minikube is not installed" in result.output + mock_delete.assert_called_once_with("test-cluster", "minikube", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_does_not_exist(self, mock_delete): + """Test error when specified cluster doesn't exist""" + mock_delete.side_effect = click.ClickException('Kind cluster "test-cluster" does not exist') + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code != 0 + assert 'Kind cluster "test-cluster" does not exist' in result.output + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_name_output(self, mock_delete): + """Test --output=name only prints the cluster name""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind", "--output", "name"]) + + assert result.exit_code == 0 + assert result.output.strip() == "test-cluster" + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_name_output_still_prompts_for_confirmation(self, mock_delete): + """Test --output=name without --force still prompts for confirmation (uses SilentWithConfirmCallback)""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind", "--output", "name"]) + + assert result.exit_code == 0 + # Verify that force=False was passed, which means confirmation should be prompted + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + # Verify the callback type is SilentWithConfirmCallback by checking its behavior + callback_arg = mock_delete.call_args[0][3] + from jumpstarter_cli_common.callbacks import SilentWithConfirmCallback + + assert isinstance(callback_arg, SilentWithConfirmCallback) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_name_output_with_force_uses_force_callback(self, mock_delete): + """Test --output=name with --force uses ForceClickCallback""" + mock_delete.return_value = None + + result = self.runner.invoke( + delete, ["cluster", "test-cluster", "--kind", "kind", "--output", "name", "--force"] + ) + + assert result.exit_code == 0 + # Verify that force=True was passed + mock_delete.assert_called_once_with("test-cluster", "kind", True, ANY) + # Verify the callback type is ForceClickCallback + callback_arg = mock_delete.call_args[0][3] + from jumpstarter_cli_common.callbacks import ForceClickCallback + + assert isinstance(callback_arg, ForceClickCallback) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_normal_output(self, mock_delete): + """Test normal output messages (mocked through delete_cluster_by_name)""" + mock_delete.return_value = None + + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + # Note: Output messages are handled by delete_cluster_by_name function itself + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_both_types_specified_behavior(self, mock_delete): + """Test that specifying both --kind and --minikube, kind takes precedence""" + mock_delete.return_value = None + + # In the delete command, kind is checked first, so it takes precedence + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "kind", "--minikube", "minikube"]) + + assert result.exit_code == 0 + # Should use kind since it's checked first in the if/elif chain + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + @patch("jumpstarter_cli_admin.delete.delete_cluster_by_name") + def test_delete_cluster_with_custom_binaries(self, mock_delete): + """Test that custom binary names are handled correctly""" + mock_delete.return_value = None + + # Test with custom kind binary name + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--kind", "my-kind"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", "kind", False, ANY) + + mock_delete.reset_mock() + + # Test with custom minikube binary name + result = self.runner.invoke(delete, ["cluster", "test-cluster", "--minikube", "my-minikube"]) + + assert result.exit_code == 0 + mock_delete.assert_called_once_with("test-cluster", "minikube", False, ANY) diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 506b0c3e0..09c63fd43 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -15,6 +15,8 @@ ClientsV1Alpha1Api, ExportersV1Alpha1Api, LeasesV1Alpha1Api, + get_cluster_info, + list_clusters, ) from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.config.config_exception import ConfigException @@ -109,3 +111,61 @@ async def get_lease( handle_k8s_api_exception(e) except ConfigException as e: handle_k8s_config_exception(e) + + +@get.command("cluster") +@click.argument("name", type=str, required=False, default=None) +@click.option( + "--type", type=click.Choice(["kind", "minikube", "remote", "all"]), default="all", help="Filter clusters by type" +) +@click.option("--kubectl", type=str, help="Path or name of kubectl executable", default="kubectl") +@click.option("--helm", type=str, help="Path or name of helm executable", default="helm") +@click.option("--kind", type=str, help="Path or name of kind executable", default="kind") +@click.option("--minikube", type=str, help="Path or name of minikube executable", default="minikube") +@opt_output_all +@blocking +async def get_cluster( + name: Optional[str], type: str, kubectl: str, helm: str, kind: str, minikube: str, output: OutputType +): + """Get information about a specific cluster or list all clusters""" + try: + if name is not None: + # Get specific cluster by context name + cluster_info = await get_cluster_info(name, kubectl, helm, minikube) + + # Check if the cluster context was found + if cluster_info.error and "not found" in cluster_info.error: + raise click.ClickException(f'Kubernetes context "{name}" not found') + + model_print(cluster_info, output) + else: + # List all clusters if no name provided + cluster_list = await list_clusters(type, kubectl, helm, kind, minikube) + model_print(cluster_list, output) + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"Error getting cluster info: {e}") from e + + +@get.command("clusters") +@click.option( + "--type", type=click.Choice(["kind", "minikube", "remote", "all"]), default="all", help="Filter clusters by type" +) +@click.option("--kubectl", type=str, help="Path or name of kubectl executable", default="kubectl") +@click.option("--helm", type=str, help="Path or name of helm executable", default="helm") +@click.option("--kind", type=str, help="Path or name of kind executable", default="kind") +@click.option("--minikube", type=str, help="Path or name of minikube executable", default="minikube") +@opt_output_all +@blocking +async def get_clusters(type: str, kubectl: str, helm: str, kind: str, minikube: str, output: OutputType): + """List all Kubernetes clusters with Jumpstarter status""" + try: + cluster_list = await list_clusters(type, kubectl, helm, kind, minikube) + + # Use model_print for all output formats + model_print(cluster_list, output) + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"Error listing clusters: {e}") from e diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index 3ac0399c2..4b12a807b 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -20,6 +20,7 @@ ) from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference +from kubernetes_asyncio.config.config_exception import ConfigException from jumpstarter_cli_admin.test_utils import json_equal @@ -1197,3 +1198,139 @@ def test_get_leases(_load_kube_config_mock, list_leases_mock: AsyncMock): result = runner.invoke(get, ["leases"]) assert result.exit_code == 0 assert "No resources found" in result.output + + +# Test ConfigException handling +@patch.object(ClientsV1Alpha1Api, "get_client") +@patch.object(ClientsV1Alpha1Api, "_load_kube_config") +def test_get_client_config_exception(_load_kube_config_mock, get_client_mock: AsyncMock): + runner = CliRunner() + get_client_mock.side_effect = ConfigException("Invalid kubeconfig") + result = runner.invoke(get, ["client", "test"]) + assert result.exit_code == 1 + assert "kubeconfig" in result.output.lower() + + +@patch.object(ExportersV1Alpha1Api, "get_exporter") +@patch.object(ExportersV1Alpha1Api, "_load_kube_config") +def test_get_exporter_config_exception(_load_kube_config_mock, get_exporter_mock: AsyncMock): + runner = CliRunner() + get_exporter_mock.side_effect = ConfigException("Invalid kubeconfig") + result = runner.invoke(get, ["exporter", "test"]) + assert result.exit_code == 1 + assert "kubeconfig" in result.output.lower() + + +@patch.object(LeasesV1Alpha1Api, "get_lease") +@patch.object(LeasesV1Alpha1Api, "_load_kube_config") +def test_get_lease_config_exception(_load_kube_config_mock, get_lease_mock: AsyncMock): + runner = CliRunner() + get_lease_mock.side_effect = ConfigException("Invalid kubeconfig") + result = runner.invoke(get, ["lease", "test"]) + assert result.exit_code == 1 + assert "kubeconfig" in result.output.lower() + + +# Test get cluster commands +@patch("jumpstarter_cli_admin.get.get_cluster_info") +def test_get_cluster_by_name(get_cluster_info_mock: AsyncMock): + from jumpstarter_kubernetes import V1Alpha1ClusterInfo, V1Alpha1JumpstarterInstance + + runner = CliRunner() + cluster_info = V1Alpha1ClusterInfo( + name="kind-test", + cluster="kind-kind-test", + server="https://127.0.0.1:6443", + user="kind-kind-test", + namespace="default", + is_current=True, + type="kind", + accessible=True, + version="1.28.0", + jumpstarter=V1Alpha1JumpstarterInstance(installed=True, version="0.1.0", namespace="jumpstarter"), + ) + get_cluster_info_mock.return_value = cluster_info + result = runner.invoke(get, ["cluster", "kind-test"]) + assert result.exit_code == 0 + assert "kind-test" in result.output + + +@patch("jumpstarter_cli_admin.get.get_cluster_info") +def test_get_cluster_not_found(get_cluster_info_mock: AsyncMock): + from jumpstarter_kubernetes import V1Alpha1ClusterInfo, V1Alpha1JumpstarterInstance + + runner = CliRunner() + cluster_info = V1Alpha1ClusterInfo( + name="nonexistent", + cluster="nonexistent", + server="", + user="", + namespace="default", + is_current=False, + type="remote", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + error="context not found", + ) + get_cluster_info_mock.return_value = cluster_info + result = runner.invoke(get, ["cluster", "nonexistent"]) + assert result.exit_code == 1 + assert "not found" in result.output.lower() + + +@patch("jumpstarter_cli_admin.get.get_cluster_info") +def test_get_cluster_error(get_cluster_info_mock: AsyncMock): + runner = CliRunner() + get_cluster_info_mock.side_effect = Exception("Unexpected error") + result = runner.invoke(get, ["cluster", "test"]) + assert result.exit_code == 1 + assert "error" in result.output.lower() + + +@patch("jumpstarter_cli_admin.get.list_clusters") +def test_get_clusters_list(list_clusters_mock: AsyncMock): + from jumpstarter_kubernetes import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance + + runner = CliRunner() + cluster_list = V1Alpha1ClusterList( + items=[ + V1Alpha1ClusterInfo( + name="kind-test", + cluster="kind-kind-test", + server="https://127.0.0.1:6443", + user="kind-kind-test", + namespace="default", + is_current=True, + type="kind", + accessible=True, + version="1.28.0", + jumpstarter=V1Alpha1JumpstarterInstance(installed=True, version="0.1.0", namespace="jumpstarter"), + ), + V1Alpha1ClusterInfo( + name="minikube", + cluster="minikube", + server="https://192.168.49.2:8443", + user="minikube", + namespace="default", + is_current=False, + type="minikube", + accessible=True, + version="1.28.0", + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ), + ] + ) + list_clusters_mock.return_value = cluster_list + result = runner.invoke(get, ["clusters"]) + assert result.exit_code == 0 + assert "kind-test" in result.output + assert "minikube" in result.output + + +@patch("jumpstarter_cli_admin.get.list_clusters") +def test_get_clusters_error(list_clusters_mock: AsyncMock): + runner = CliRunner() + list_clusters_mock.side_effect = Exception("Unexpected error") + result = runner.invoke(get, ["clusters"]) + assert result.exit_code == 1 + assert "error" in result.output.lower() diff --git a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py index 042aa9871..6f3ced67a 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install.py @@ -3,32 +3,22 @@ import click from jumpstarter_cli_common.blocking import blocking from jumpstarter_cli_common.opt import opt_context, opt_kubeconfig +from jumpstarter_cli_common.version import get_client_version from jumpstarter_kubernetes import ( - create_kind_cluster, - create_minikube_cluster, - delete_kind_cluster, - delete_minikube_cluster, + get_latest_compatible_controller_version, 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 _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"]]: + """Validate cluster type selection - returns None if neither is specified""" if kind and minikube: raise click.ClickException('You can only select one local cluster type "kind" or "minikube"') @@ -36,7 +26,15 @@ def _validate_cluster_type( return "kind" elif minikube is not None: return "minikube" - return None + else: + return 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 " + ) async def _configure_endpoints( @@ -60,131 +58,6 @@ async def _configure_endpoints( 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)") - - 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, @@ -198,6 +71,7 @@ async def _install_jumpstarter_helm_chart( context: Optional[str], helm: str, ip: str, + values_files: Optional[list[str]] = None, ) -> None: click.echo(f'Installing Jumpstarter service v{version} in namespace "{namespace}" with Helm\n') click.echo(f"Chart URI: {chart}") @@ -209,7 +83,18 @@ async def _install_jumpstarter_helm_chart( click.echo(f"gPRC Mode: {mode}\n") await install_helm_chart( - chart, name, namespace, basedomain, grpc_endpoint, router_endpoint, mode, version, kubeconfig, context, helm + chart, + name, + namespace, + basedomain, + grpc_endpoint, + router_endpoint, + mode, + version, + kubeconfig, + context, + helm, + values_files, ) click.echo(f'Installed Helm release "{name}" in namespace "{namespace}"') @@ -261,16 +146,15 @@ async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_nam 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("-v", "--version", help="The version of the service to install", default=None) @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)", + "-f", + "--values-file", + "values_files", + type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True), + multiple=True, + help="Path to custom helm values file (can be specified multiple times)", ) -@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 @blocking @@ -286,12 +170,8 @@ async def install( mode: Literal["nodeport"] | Literal["ingress"] | Literal["route"], 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, + values_files: tuple[str, ...], kubeconfig: Optional[str], context: Optional[str], ): @@ -300,26 +180,27 @@ async def install( cluster_type = _validate_cluster_type(kind, minikube) - 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 + cluster_type, minikube or "minikube", "jumpstarter-lab", ip, basedomain, grpc_endpoint, router_endpoint ) if version is None: - version = await get_latest_compatible_controller_version() + version = await get_latest_compatible_controller_version(get_client_version()) await _install_jumpstarter_helm_chart( - chart, name, namespace, basedomain, grpc_endpoint, router_endpoint, mode, version, kubeconfig, context, helm, ip + chart, + name, + namespace, + basedomain, + grpc_endpoint, + router_endpoint, + mode, + version, + kubeconfig, + context, + helm, + ip, + list(values_files) if values_files else None, ) @@ -354,18 +235,6 @@ 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 @@ -373,10 +242,6 @@ 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], ): @@ -388,6 +253,3 @@ async def uninstall( 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-admin/jumpstarter_cli_admin/install_test.py b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py index b75c77fd8..eb2406532 100644 --- a/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py +++ b/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/install_test.py @@ -3,18 +3,13 @@ import click import pytest from click.testing import CliRunner +from jumpstarter_kubernetes.callbacks import SilentCallback +from jumpstarter_kubernetes.cluster.kind import create_kind_cluster_with_options +from jumpstarter_kubernetes.cluster.minikube import create_minikube_cluster_with_options +from jumpstarter_kubernetes.cluster.operations import validate_cluster_type_selection 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, ) @@ -36,660 +31,107 @@ def test_validate_prerequisites_helm_not_installed(self, mock_helm_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") + """Test that error is raised when both kind and minikube are specified.""" + from jumpstarter_kubernetes.exceptions import ClusterTypeValidationError + + with pytest.raises( + ClusterTypeValidationError, match='You can only select one local cluster type "kind" or "minikube"' + ): + validate_cluster_type_selection("kind", "minikube") def test_validate_cluster_type_kind_only(self): - result = _validate_cluster_type("kind", None) + """Test that 'kind' is returned when only kind is specified.""" + result = validate_cluster_type_selection("kind", None) assert result == "kind" def test_validate_cluster_type_minikube_only(self): - result = _validate_cluster_type(None, "minikube") + """Test that 'minikube' is returned when only minikube is specified.""" + result = validate_cluster_type_selection(None, "minikube") assert result == "minikube" - def test_validate_cluster_type_neither(self): - result = _validate_cluster_type(None, None) - assert result is None + # Note: test_validate_cluster_type_auto_detect removed as this function + # is now tested in the jumpstarter-kubernetes library 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, - ) + """Test endpoint configuration functions.""" - 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" + # Note: test_configure_endpoints_minikube removed as this function + # is now tested in the jumpstarter-kubernetes library 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) + """Test cluster creation functions.""" - @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) + # Note: Tests for _handle_cluster_creation, _create_kind_cluster, and _create_minikube_cluster + # have been removed as these functions no longer exist after the refactoring. + # The functionality is now tested through the new create_kind_cluster_with_options + # and create_minikube_cluster_with_options functions in their respective modules. @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.""" + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.create_kind_cluster") + async def test_create_kind_cluster_with_options_success(self, mock_create_kind, mock_kind_installed): + """Test creating a Kind cluster with the new function structure.""" - @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 + callback = SilentCallback() - await _create_kind_cluster("kind", "test-cluster", "--verbosity=1", False) + await create_kind_cluster_with_options( + "kind", "test-cluster", "--verbosity=1", False, None, callback + ) - mock_create_kind.assert_called_once_with("kind", "test-cluster", ["--verbosity=1"], False) + mock_create_kind.assert_called_once() @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 + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + async def test_create_kind_cluster_with_options_not_installed(self, mock_kind_installed): + """Test that ToolNotInstalledError is raised when kind is not installed.""" + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError - 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") + mock_kind_installed.return_value = False + callback = SilentCallback() - with pytest.raises(click.ClickException, match="Failed to create Kind cluster"): - await _create_kind_cluster("kind", "test-cluster", "", False) + with pytest.raises(ToolNotInstalledError, match="kind is not installed"): + await create_kind_cluster_with_options( + "kind", "test-cluster", "", False, None, callback + ) @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): + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.create_minikube_cluster") + async def test_create_minikube_cluster_with_options_success(self, mock_create_minikube, mock_minikube_installed): + """Test creating a Minikube cluster with the new function structure.""" mock_minikube_installed.return_value = True mock_create_minikube.return_value = True + callback = SilentCallback() - 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 + await create_minikube_cluster_with_options( + "minikube", "test-cluster", "--memory=4096", False, None, callback + ) - with pytest.raises(click.ClickException, match="minikube is not installed"): - await _create_minikube_cluster("minikube", "test-cluster", "", False) + mock_create_minikube.assert_called_once() 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, 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") - - 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 + # Note: test_get_ip_generic_minikube and test_get_ip_generic_fallback removed + # as these functions are now tested in the jumpstarter-kubernetes library - # 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 +class TestCLICommands: + """Test CLI command execution.""" def test_install_command_help(self): - result = self.runner.invoke(install, ["--help"]) - + runner = CliRunner() + result = 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") + assert "Install the Jumpstarter service" in result.output def test_uninstall_command_help(self): - result = self.runner.invoke(uninstall, ["--help"]) - + runner = CliRunner() + result = 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 + assert "Uninstall the Jumpstarter service" in result.output diff --git a/packages/jumpstarter-cli-admin/pyproject.toml b/packages/jumpstarter-cli-admin/pyproject.toml index d2855edf1..c8278da56 100644 --- a/packages/jumpstarter-cli-admin/pyproject.toml +++ b/packages/jumpstarter-cli-admin/pyproject.toml @@ -7,11 +7,9 @@ readme = "README.md" license = "Apache-2.0" requires-python = ">=3.11" dependencies = [ - "aiohttp>=3.11.18", + "grpcio-reflection>=1.60.0", "jumpstarter-cli-common", "jumpstarter-kubernetes", - "packaging>=25.0", - "semver~=2.13", ] [dependency-groups] diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/callbacks.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/callbacks.py new file mode 100644 index 000000000..78034a0c4 --- /dev/null +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/callbacks.py @@ -0,0 +1,75 @@ +"""CLI callback adapter for jumpstarter-kubernetes library. + +This module provides a callback implementation that adapts the library's +callback interface to CLI-specific behavior using click. +""" + +import click + + +class ClickCallback: + """Callback that uses click for output and user interaction.""" + + def __init__(self, silent: bool = False): + """Initialize callback. + + Args: + silent: If True, suppress all output (useful for --output=name mode) + """ + self.silent = silent + + def progress(self, message: str) -> None: + """Display a progress or informational message.""" + if not self.silent: + click.echo(message) + + def success(self, message: str) -> None: + """Display a success message.""" + if not self.silent: + click.echo(message) + + def warning(self, message: str) -> None: + """Display a warning message.""" + if not self.silent: + click.echo(message) + + def error(self, message: str) -> None: + """Display an error message.""" + if not self.silent: + click.echo(message, err=True) + + def confirm(self, prompt: str) -> bool: + """Ask user for confirmation.""" + if self.silent: + # In silent mode, we can't ask for confirmation + # This should only happen if the function is called with force=True + return True + return click.confirm(prompt) + + +class ForceClickCallback(ClickCallback): + """Callback for force mode operations that skips confirmations.""" + + def __init__(self, silent: bool = False): + """Initialize force callback.""" + super().__init__(silent) + + def confirm(self, prompt: str) -> bool: + """Always returns True (force mode skips confirmations).""" + return True + + +class SilentWithConfirmCallback(ClickCallback): + """Callback that suppresses output but still prompts for confirmation. + + This is useful for --output=name mode where we want clean output + but still need user confirmation for destructive operations. + """ + + def __init__(self): + """Initialize silent callback that still prompts.""" + super().__init__(silent=True) + + def confirm(self, prompt: str) -> bool: + """Ask user for confirmation even in silent mode.""" + return click.confirm(prompt) diff --git a/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py b/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py index 1d7a75e6f..a3d99c571 100644 --- a/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py +++ b/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py @@ -17,7 +17,8 @@ def model_print( # noqa: C901 match output: case OutputMode.JSON: console.print_json( - data=model.model_dump_json( + data=model.model_dump( + mode="json", by_alias=True, ), indent=4, diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 2d4058fac..668093345 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -1,12 +1,24 @@ from .clients import ClientsV1Alpha1Api, V1Alpha1Client, V1Alpha1ClientList, V1Alpha1ClientStatus from .cluster import ( + check_jumpstarter_installation, + create_cluster_and_install, + create_cluster_only, create_kind_cluster, create_minikube_cluster, + delete_cluster_by_name, delete_kind_cluster, delete_minikube_cluster, + detect_cluster_type, + get_cluster_info, + get_ip_generic, kind_installed, + list_clusters, + list_kubectl_contexts, minikube_installed, + validate_cluster_type_selection, ) +from .clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance +from .controller import get_latest_compatible_controller_version from .exporters import ( ExportersV1Alpha1Api, V1Alpha1Exporter, @@ -34,6 +46,9 @@ "V1Alpha1Client", "V1Alpha1ClientList", "V1Alpha1ClientStatus", + "V1Alpha1ClusterInfo", + "V1Alpha1ClusterList", + "V1Alpha1JumpstarterInstance", "ExportersV1Alpha1Api", "V1Alpha1Exporter", "V1Alpha1ExporterList", @@ -55,4 +70,15 @@ "create_kind_cluster", "delete_minikube_cluster", "delete_kind_cluster", + "create_cluster_and_install", + "create_cluster_only", + "delete_cluster_by_name", + "get_cluster_info", + "list_clusters", + "get_ip_generic", + "list_kubectl_contexts", + "detect_cluster_type", + "check_jumpstarter_installation", + "validate_cluster_type_selection", + "get_latest_compatible_controller_version", ] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py new file mode 100644 index 000000000..6294c1ef9 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py @@ -0,0 +1,124 @@ +"""Callback system for output and user interaction. + +This module provides a clean interface for handling output and user confirmations +without depending on CLI frameworks like click. This allows the library code to +be used in different contexts (CLI, web API, GUI, etc.). +""" + +import logging +from typing import Protocol + + +class OutputCallback(Protocol): + """Protocol for handling output and user interaction.""" + + def progress(self, message: str) -> None: + """Display a progress or informational message.""" + ... + + def success(self, message: str) -> None: + """Display a success message.""" + ... + + def warning(self, message: str) -> None: + """Display a warning message.""" + ... + + def error(self, message: str) -> None: + """Display an error message.""" + ... + + def confirm(self, prompt: str) -> bool: + """Ask user for confirmation. Returns True if confirmed, False otherwise.""" + ... + + +class SilentCallback: + """Callback that produces no output and auto-confirms all prompts. + + Useful for scripting scenarios or when output should be suppressed + (e.g., when using --output=name in CLI). + """ + + def progress(self, message: str) -> None: + """Does nothing.""" + pass + + def success(self, message: str) -> None: + """Does nothing.""" + pass + + def warning(self, message: str) -> None: + """Does nothing.""" + pass + + def error(self, message: str) -> None: + """Does nothing.""" + pass + + def confirm(self, prompt: str) -> bool: + """Always returns True (auto-confirm).""" + return True + + +class LoggingCallback: + """Callback that uses Python's logging system. + + Useful for server applications or when you want structured logging. + """ + + def __init__(self, logger: logging.Logger = None): + """Initialize with optional logger. If None, uses root logger.""" + self.logger = logger or logging.getLogger(__name__) + + def progress(self, message: str) -> None: + """Log as INFO level.""" + self.logger.info(message) + + def success(self, message: str) -> None: + """Log as INFO level.""" + self.logger.info(message) + + def warning(self, message: str) -> None: + """Log as WARNING level.""" + self.logger.warning(message) + + def error(self, message: str) -> None: + """Log as ERROR level.""" + self.logger.error(message) + + def confirm(self, prompt: str) -> bool: + """Log the prompt and return True (auto-confirm for logging mode).""" + self.logger.info(f"Confirmation requested: {prompt} (auto-confirmed)") + return True + + +class ForceCallback: + """Callback for force mode operations. + + Skips all confirmations and produces minimal output. + """ + + def __init__(self, output_callback: OutputCallback = None): + """Initialize with optional output callback for messages.""" + self.output_callback = output_callback or SilentCallback() + + def progress(self, message: str) -> None: + """Forward to output callback.""" + self.output_callback.progress(message) + + def success(self, message: str) -> None: + """Forward to output callback.""" + self.output_callback.success(message) + + def warning(self, message: str) -> None: + """Forward to output callback.""" + self.output_callback.warning(message) + + def error(self, message: str) -> None: + """Forward to output callback.""" + self.output_callback.error(message) + + def confirm(self, prompt: str) -> bool: + """Always returns True (force mode skips confirmations).""" + return True diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks_test.py new file mode 100644 index 000000000..859b9be2b --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks_test.py @@ -0,0 +1,136 @@ +import logging +from unittest.mock import MagicMock + +from .callbacks import ForceCallback, LoggingCallback, SilentCallback + + +class TestSilentCallback: + """Test the SilentCallback class.""" + + def test_silent_callback_progress(self): + """Test that progress does nothing""" + callback = SilentCallback() + callback.progress("test message") # Should not raise + + def test_silent_callback_success(self): + """Test that success does nothing""" + callback = SilentCallback() + callback.success("test message") # Should not raise + + def test_silent_callback_warning(self): + """Test that warning does nothing""" + callback = SilentCallback() + callback.warning("test message") # Should not raise + + def test_silent_callback_error(self): + """Test that error does nothing""" + callback = SilentCallback() + callback.error("test message") # Should not raise + + def test_silent_callback_confirm(self): + """Test that confirm always returns True""" + callback = SilentCallback() + assert callback.confirm("Are you sure?") is True + + +class TestLoggingCallback: + """Test the LoggingCallback class.""" + + def test_logging_callback_with_default_logger(self): + """Test LoggingCallback with default logger""" + callback = LoggingCallback() + assert callback.logger is not None + + def test_logging_callback_with_custom_logger(self): + """Test LoggingCallback with custom logger""" + logger = logging.getLogger("test") + callback = LoggingCallback(logger) + assert callback.logger == logger + + def test_logging_callback_progress(self): + """Test that progress logs at INFO level""" + mock_logger = MagicMock(spec=logging.Logger) + callback = LoggingCallback(mock_logger) + callback.progress("test message") + mock_logger.info.assert_called_once_with("test message") + + def test_logging_callback_success(self): + """Test that success logs at INFO level""" + mock_logger = MagicMock(spec=logging.Logger) + callback = LoggingCallback(mock_logger) + callback.success("test message") + mock_logger.info.assert_called_once_with("test message") + + def test_logging_callback_warning(self): + """Test that warning logs at WARNING level""" + mock_logger = MagicMock(spec=logging.Logger) + callback = LoggingCallback(mock_logger) + callback.warning("test message") + mock_logger.warning.assert_called_once_with("test message") + + def test_logging_callback_error(self): + """Test that error logs at ERROR level""" + mock_logger = MagicMock(spec=logging.Logger) + callback = LoggingCallback(mock_logger) + callback.error("test message") + mock_logger.error.assert_called_once_with("test message") + + def test_logging_callback_confirm(self): + """Test that confirm logs and returns True""" + mock_logger = MagicMock(spec=logging.Logger) + callback = LoggingCallback(mock_logger) + result = callback.confirm("Are you sure?") + assert result is True + mock_logger.info.assert_called_once_with("Confirmation requested: Are you sure? (auto-confirmed)") + + +class TestForceCallback: + """Test the ForceCallback class.""" + + def test_force_callback_with_default_output(self): + """Test ForceCallback with default SilentCallback""" + callback = ForceCallback() + assert isinstance(callback.output_callback, SilentCallback) + + def test_force_callback_with_custom_output(self): + """Test ForceCallback with custom output callback""" + custom_callback = MagicMock() + callback = ForceCallback(custom_callback) + assert callback.output_callback == custom_callback + + def test_force_callback_progress(self): + """Test that progress forwards to output callback""" + mock_output = MagicMock() + callback = ForceCallback(mock_output) + callback.progress("test message") + mock_output.progress.assert_called_once_with("test message") + + def test_force_callback_success(self): + """Test that success forwards to output callback""" + mock_output = MagicMock() + callback = ForceCallback(mock_output) + callback.success("test message") + mock_output.success.assert_called_once_with("test message") + + def test_force_callback_warning(self): + """Test that warning forwards to output callback""" + mock_output = MagicMock() + callback = ForceCallback(mock_output) + callback.warning("test message") + mock_output.warning.assert_called_once_with("test message") + + def test_force_callback_error(self): + """Test that error forwards to output callback""" + mock_output = MagicMock() + callback = ForceCallback(mock_output) + callback.error("test message") + mock_output.error.assert_called_once_with("test message") + + def test_force_callback_confirm(self): + """Test that confirm always returns True without forwarding""" + mock_output = MagicMock() + callback = ForceCallback(mock_output) + result = callback.confirm("Are you sure?") + assert result is True + # Confirm should NOT be forwarded to output callback + mock_output.confirm.assert_not_called() diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index d42383294..dfb4d27e6 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -1,3 +1,4 @@ + from kubernetes_asyncio.client.models import V1ObjectMeta from jumpstarter_kubernetes import V1Alpha1Client, V1Alpha1ClientStatus @@ -56,3 +57,180 @@ def test_client_dump_yaml(): endpoint: https://test-client """ ) + + +def test_client_from_dict_with_credential(): + """Test V1Alpha1Client.from_dict with credential""" + + test_dict = { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-client", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": {"credential": {"name": "test-credential"}, "endpoint": "https://test-client"}, + } + client = V1Alpha1Client.from_dict(test_dict) + assert client.metadata.name == "test-client" + assert client.status.endpoint == "https://test-client" + assert client.status.credential.name == "test-credential" + + +def test_client_from_dict_without_credential(): + """Test V1Alpha1Client.from_dict without credential""" + test_dict = { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-client", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": {"endpoint": "https://test-client"}, + } + client = V1Alpha1Client.from_dict(test_dict) + assert client.metadata.name == "test-client" + assert client.status.endpoint == "https://test-client" + assert client.status.credential is None + + +def test_client_from_dict_without_status(): + """Test V1Alpha1Client.from_dict without status""" + test_dict = { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-client", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + } + client = V1Alpha1Client.from_dict(test_dict) + assert client.metadata.name == "test-client" + assert client.status is None + + +def test_client_rich_add_columns(): + """Test V1Alpha1Client.rich_add_columns""" + from unittest.mock import MagicMock + + mock_table = MagicMock() + V1Alpha1Client.rich_add_columns(mock_table) + assert mock_table.add_column.call_count == 2 + mock_table.add_column.assert_any_call("NAME", no_wrap=True) + mock_table.add_column.assert_any_call("ENDPOINT") + + +def test_client_rich_add_rows_with_status(): + """Test V1Alpha1Client.rich_add_rows with status""" + from unittest.mock import MagicMock + + mock_table = MagicMock() + TEST_CLIENT.rich_add_rows(mock_table) + mock_table.add_row.assert_called_once_with("test-client", "https://test-client") + + +def test_client_rich_add_rows_without_status(): + """Test V1Alpha1Client.rich_add_rows without status""" + from unittest.mock import MagicMock + + client = V1Alpha1Client( + api_version="jumpstarter.dev/v1alpha1", + kind="Client", + metadata=V1ObjectMeta(name="test-client", namespace="default"), + status=None, + ) + mock_table = MagicMock() + client.rich_add_rows(mock_table) + mock_table.add_row.assert_called_once_with("test-client", "") + + +def test_client_rich_add_names(): + """Test V1Alpha1Client.rich_add_names""" + names = [] + TEST_CLIENT.rich_add_names(names) + assert names == ["client.jumpstarter.dev/test-client"] + + +def test_client_list_from_dict(): + """Test V1Alpha1ClientList.from_dict""" + from jumpstarter_kubernetes import V1Alpha1ClientList + + test_dict = { + "items": [ + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "client1", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": {"endpoint": "https://client1"}, + }, + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Client", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "client2", + "namespace": "default", + "resourceVersion": "1", + "uid": "8b36fc92-7554-48fd-b73g-61276cggfef9", + }, + "status": {"endpoint": "https://client2"}, + }, + ] + } + client_list = V1Alpha1ClientList.from_dict(test_dict) + assert len(client_list.items) == 2 + assert client_list.items[0].metadata.name == "client1" + assert client_list.items[1].metadata.name == "client2" + + +def test_client_list_rich_add_columns(): + """Test V1Alpha1ClientList.rich_add_columns""" + from unittest.mock import MagicMock + + from jumpstarter_kubernetes import V1Alpha1ClientList + + mock_table = MagicMock() + V1Alpha1ClientList.rich_add_columns(mock_table) + assert mock_table.add_column.call_count == 2 + + +def test_client_list_rich_add_rows(): + """Test V1Alpha1ClientList.rich_add_rows""" + from unittest.mock import MagicMock + + from jumpstarter_kubernetes import V1Alpha1ClientList + + client_list = V1Alpha1ClientList(items=[TEST_CLIENT]) + mock_table = MagicMock() + client_list.rich_add_rows(mock_table) + assert mock_table.add_row.call_count == 1 + + +def test_client_list_rich_add_names(): + """Test V1Alpha1ClientList.rich_add_names""" + from jumpstarter_kubernetes import V1Alpha1ClientList + + client_list = V1Alpha1ClientList(items=[TEST_CLIENT]) + names = [] + client_list.rich_add_names(names) + assert names == ["client.jumpstarter.dev/test-client"] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py deleted file mode 100644 index 11657e42c..000000000 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster.py +++ /dev/null @@ -1,201 +0,0 @@ -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 - - has_cpus_flag = any(a == "--cpus" or a.startswith("--cpus=") for a in extra_args) - if not has_cpus_flag: - try: - rc, out, _ = await run_command([minikube, "config", "get", "cpus"]) - has_config_cpus = rc == 0 and out.strip().isdigit() and int(out.strip()) > 0 - except RuntimeError: - # If we cannot query minikube (e.g., not installed in test env), default CPUs - has_config_cpus = False - if not has_config_cpus: - extra_args.append("--cpus=4") - - 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/cluster/__init__.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py new file mode 100644 index 000000000..a7cf5043b --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py @@ -0,0 +1,117 @@ +"""Cluster management module for Jumpstarter Kubernetes operations. + +This module provides comprehensive cluster management functionality including: +- Kind and Minikube cluster operations +- Helm chart management +- Kubectl operations +- Cluster detection and endpoint configuration +- High-level orchestration operations + +For backward compatibility, all functions from the original cluster.py are re-exported here. +""" + + +# Re-export all public functions for backward compatibility +# Common utilities and types +from .common import ( + ClusterType, + format_cluster_name, + get_extra_certs_path, + run_command, + run_command_with_output, + validate_cluster_name, + validate_cluster_type, +) + +# Detection and endpoints +from .detection import auto_detect_cluster_type, detect_cluster_type, detect_existing_cluster_type +from .endpoints import configure_endpoints, get_ip_generic + +# Helm operations +from .helm import install_jumpstarter_helm_chart + +# Kind cluster operations +from .kind import ( + create_kind_cluster, + delete_kind_cluster, + kind_cluster_exists, + kind_installed, + list_kind_clusters, +) + +# Kubectl operations +from .kubectl import ( + check_jumpstarter_installation, + check_kubernetes_access, + get_cluster_info, + get_kubectl_contexts, + list_clusters, +) +from .kubectl import get_kubectl_contexts as list_kubectl_contexts + +# Minikube cluster operations +from .minikube import ( + create_minikube_cluster, + delete_minikube_cluster, + get_minikube_cluster_ip, + list_minikube_clusters, + minikube_cluster_exists, + minikube_installed, +) + +# High-level operations +from .operations import ( + create_cluster_and_install, + create_cluster_only, + delete_cluster_by_name, + validate_cluster_type_selection, +) + +# All module functions are imported above and available through clean re-exports + +# Re-export all functions that were available in the original cluster.py +__all__ = [ + # Types + "ClusterType", + # Common utilities + "validate_cluster_name", + "validate_cluster_type", + "format_cluster_name", + "get_extra_certs_path", + # Kind operations + "kind_installed", + "kind_cluster_exists", + "create_kind_cluster", + "delete_kind_cluster", + "list_kind_clusters", + # Minikube operations + "minikube_installed", + "minikube_cluster_exists", + "create_minikube_cluster", + "delete_minikube_cluster", + "list_minikube_clusters", + "get_minikube_cluster_ip", + # Helm operations + "install_jumpstarter_helm_chart", + # Kubectl operations + "check_kubernetes_access", + "get_kubectl_contexts", + "list_kubectl_contexts", + "check_jumpstarter_installation", + "get_cluster_info", + "list_clusters", + # Detection and configuration + "auto_detect_cluster_type", + "detect_cluster_type", + "detect_existing_cluster_type", + "get_ip_generic", + "configure_endpoints", + # High-level operations + "create_cluster_and_install", + "create_cluster_only", + "delete_cluster_by_name", + "validate_cluster_type_selection", + # Utility functions + "run_command", + "run_command_with_output", +] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py new file mode 100644 index 000000000..15fa5a05a --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py @@ -0,0 +1,95 @@ +"""Common utilities and types for cluster operations.""" + +import asyncio +import os +from typing import Literal, Optional + +from ..exceptions import ClusterTypeValidationError + +ClusterType = Literal["kind"] | Literal["minikube"] + + +def validate_cluster_type( + kind: Optional[str], minikube: Optional[str] +) -> Optional[ClusterType]: + """Validate cluster type selection - returns None if neither is specified.""" + if kind and minikube: + raise ClusterTypeValidationError('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" + else: + return None + + +def get_extra_certs_path(extra_certs: Optional[str]) -> Optional[str]: + """Get the absolute path to extra certificates file if provided. + + Expands ~ (tilde) and environment variables before resolving to absolute path. + """ + if extra_certs is None: + return None + # Expand ~ and environment variables (like $HOME, $VAR) before making absolute + expanded_path = os.path.expanduser(os.path.expandvars(extra_certs)) + return os.path.abspath(expanded_path) + + +def format_cluster_name(cluster_name: str) -> str: + """Format cluster name for consistent display.""" + return cluster_name.strip() + + +def validate_cluster_name(cluster_name: str) -> str: + """Validate and format cluster name.""" + if not cluster_name or not cluster_name.strip(): + from ..exceptions import ClusterNameValidationError + raise ClusterNameValidationError(cluster_name, "Cluster name cannot be empty") + return format_cluster_name(cluster_name) + + +async def run_command(cmd: list[str]) -> tuple[int, str, str]: + """Run a command and return exit code, stdout, stderr.""" + import builtins + + # Guard against empty command list + if not cmd: + raise ValueError("Command list cannot be empty") + + try: + process = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + # Use safe decoding to avoid UnicodeDecodeError + stdout_str = stdout.decode(errors="replace").strip() + stderr_str = stderr.decode(errors="replace").strip() + + return process.returncode, stdout_str, stderr_str + except builtins.FileNotFoundError as e: + raise RuntimeError(f"Command not found: {cmd[0]}") from e + except PermissionError as e: + raise RuntimeError(f"Permission denied executing command: {cmd[0]} - {e}") from e + except OSError as e: + raise RuntimeError(f"OS error executing command '{cmd[0]}': {e}") from e + + +async def run_command_with_output(cmd: list[str]) -> int: + """Run a command with real-time output streaming and return exit code.""" + import builtins + + # Guard against empty command list + if not cmd: + raise ValueError("Command list cannot be empty") + + try: + process = await asyncio.create_subprocess_exec(*cmd) + return await process.wait() + except builtins.FileNotFoundError as e: + raise RuntimeError(f"Command not found: {cmd[0]}") from e + except PermissionError as e: + raise RuntimeError(f"Permission denied executing command: {cmd[0]} - {e}") from e + except OSError as e: + raise RuntimeError(f"OS error executing command '{cmd[0]}': {e}") from e diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py new file mode 100644 index 000000000..2ee4cbb9e --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py @@ -0,0 +1,252 @@ +"""Tests for common cluster utilities and types.""" + +import asyncio +import os +import tempfile +from unittest.mock import AsyncMock, patch + +import pytest + +from jumpstarter_kubernetes.cluster.common import ( + ClusterType, + format_cluster_name, + get_extra_certs_path, + run_command, + run_command_with_output, + validate_cluster_name, + validate_cluster_type, +) + + +class TestClusterType: + """Test ClusterType type definition.""" + + def test_cluster_type_kind(self): + cluster_type: ClusterType = "kind" + assert cluster_type == "kind" + + def test_cluster_type_minikube(self): + cluster_type: ClusterType = "minikube" + assert cluster_type == "minikube" + + +class TestValidateClusterType: + """Test cluster type validation.""" + + 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 + + def test_validate_cluster_type_both_raises_error(self): + from jumpstarter_kubernetes.exceptions import ClusterTypeValidationError + + with pytest.raises( + ClusterTypeValidationError, match='You can only select one local cluster type "kind" or "minikube"' + ): + validate_cluster_type("kind", "minikube") + + def test_validate_cluster_type_empty_strings(self): + # Empty strings are not None, so first non-None value is returned + result = validate_cluster_type("", "") + assert result == "kind" # First parameter is returned since "" is not None + + def test_validate_cluster_type_kind_with_empty_minikube(self): + result = validate_cluster_type("kind", "") + assert result == "kind" + + def test_validate_cluster_type_minikube_with_empty_kind(self): + result = validate_cluster_type("", "minikube") + assert result == "kind" # Empty string is not None, so kind is returned + + +class TestGetExtraCertsPath: + """Test extra certificates path handling.""" + + def test_get_extra_certs_path_none(self): + result = get_extra_certs_path(None) + assert result is None + + def test_get_extra_certs_path_relative(self): + with tempfile.TemporaryDirectory() as temp_dir: + cert_file = "test.crt" + temp_cert_path = os.path.join(temp_dir, cert_file) + + # Create a temporary cert file + with open(temp_cert_path, "w") as f: + f.write("test cert") + + # Change to temp directory to test relative path + original_cwd = os.getcwd() + try: + os.chdir(temp_dir) + result = get_extra_certs_path(cert_file) + expected = os.path.abspath(cert_file) + assert result == expected + assert os.path.isabs(result) + finally: + os.chdir(original_cwd) + + def test_get_extra_certs_path_absolute(self): + with tempfile.NamedTemporaryFile(suffix=".crt") as temp_file: + result = get_extra_certs_path(temp_file.name) + assert result == temp_file.name + assert os.path.isabs(result) + + def test_get_extra_certs_path_nonexistent(self): + # Function should still return absolute path even if file doesn't exist + nonexistent_path = "/nonexistent/path/test.crt" + result = get_extra_certs_path(nonexistent_path) + assert result == nonexistent_path + assert os.path.isabs(result) + + @patch("os.path.abspath") + def test_get_extra_certs_path_calls_abspath(self, mock_abspath): + mock_abspath.return_value = "/absolute/path/test.crt" + result = get_extra_certs_path("test.crt") + mock_abspath.assert_called_once_with("test.crt") + assert result == "/absolute/path/test.crt" + + +class TestFormatClusterName: + """Test cluster name formatting.""" + + def test_format_cluster_name_normal(self): + result = format_cluster_name("test-cluster") + assert result == "test-cluster" + + def test_format_cluster_name_with_whitespace(self): + result = format_cluster_name(" test-cluster ") + assert result == "test-cluster" + + def test_format_cluster_name_with_tabs(self): + result = format_cluster_name("\ttest-cluster\t") + assert result == "test-cluster" + + def test_format_cluster_name_with_newlines(self): + result = format_cluster_name("\ntest-cluster\n") + assert result == "test-cluster" + + def test_format_cluster_name_empty(self): + result = format_cluster_name("") + assert result == "" + + def test_format_cluster_name_only_whitespace(self): + result = format_cluster_name(" ") + assert result == "" + + +class TestValidateClusterName: + """Test cluster name validation.""" + + def test_validate_cluster_name_valid(self): + result = validate_cluster_name("test-cluster") + assert result == "test-cluster" + + def test_validate_cluster_name_with_whitespace(self): + result = validate_cluster_name(" test-cluster ") + assert result == "test-cluster" + + def test_validate_cluster_name_empty_raises_error(self): + from jumpstarter_kubernetes.exceptions import ClusterNameValidationError + + with pytest.raises(ClusterNameValidationError, match="Cluster name cannot be empty"): + validate_cluster_name("") + + def test_validate_cluster_name_only_whitespace_raises_error(self): + from jumpstarter_kubernetes.exceptions import ClusterNameValidationError + + with pytest.raises(ClusterNameValidationError, match="Cluster name cannot be empty"): + validate_cluster_name(" ") + + def test_validate_cluster_name_none_raises_error(self): + from jumpstarter_kubernetes.exceptions import ClusterNameValidationError + + # This would be caught by type checking, but test runtime behavior + with pytest.raises(ClusterNameValidationError, match="Cluster name cannot be empty"): + validate_cluster_name(None) + + def test_validate_cluster_name_with_special_chars(self): + result = validate_cluster_name("test-cluster_123") + assert result == "test-cluster_123" + + def test_validate_cluster_name_numeric(self): + result = validate_cluster_name("123") + assert result == "123" + + +class TestRunCommand: + """Test run_command function.""" + + @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"]) + + @pytest.mark.asyncio + async def test_run_command_with_output_failure(self): + with patch("asyncio.create_subprocess_exec") as mock_subprocess: + mock_process = AsyncMock() + mock_process.wait.return_value = 1 + mock_subprocess.return_value = mock_process + + returncode = await run_command_with_output(["false"]) + + assert returncode == 1 diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py new file mode 100644 index 000000000..9fe82997f --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py @@ -0,0 +1,147 @@ +"""Cluster detection and type identification logic.""" + +import json +import re +import shutil +from typing import Literal, Optional + +from ..exceptions import ToolNotInstalledError +from .common import run_command +from .kind import kind_cluster_exists, kind_installed +from .minikube import minikube_cluster_exists, minikube_installed + + +def detect_container_runtime() -> str: + """Detect available container runtime for Kind.""" + if shutil.which("docker"): + return "docker" + elif shutil.which("podman"): + return "podman" + elif shutil.which("nerdctl"): + return "nerdctl" + else: + raise ToolNotInstalledError( + "container runtime", + "No supported container runtime found in PATH. Kind requires docker, podman, or nerdctl." + ) + + +async def detect_kind_provider(cluster_name: str) -> tuple[str, str]: + """Detect Kind provider and return (runtime, node_name).""" + runtime = detect_container_runtime() + + # Try to detect the actual node name by listing containers/pods + possible_names = [ + f"{cluster_name}-control-plane", + f"kind-{cluster_name}-control-plane", + f"{cluster_name}-worker", + f"kind-{cluster_name}-worker", + ] + + # Special case for default cluster name + if cluster_name == "kind": + possible_names.insert(0, "kind-control-plane") + + for node_name in possible_names: + try: + # Check if container/node exists + check_cmd = [runtime, "inspect", node_name] + returncode, _, _ = await run_command(check_cmd) + if returncode == 0: + return runtime, node_name + except RuntimeError: + continue + + # Default fallback + return runtime, f"{cluster_name}-control-plane" + + +async def detect_existing_cluster_type(cluster_name: str) -> Optional[Literal["kind"] | Literal["minikube"]]: + """Detect which type of cluster exists with the given name.""" + kind_exists = False + minikube_exists = False + + # Check if Kind cluster exists + if kind_installed("kind"): + try: + kind_exists = await kind_cluster_exists("kind", cluster_name) + except RuntimeError: + kind_exists = False + + # Check if Minikube cluster exists + if minikube_installed("minikube"): + try: + minikube_exists = await minikube_cluster_exists("minikube", cluster_name) + except RuntimeError: + minikube_exists = False + + if kind_exists and minikube_exists: + from ..exceptions import ClusterOperationError + raise ClusterOperationError( + "detect", + cluster_name, + "multiple", + Exception( + f'Both Kind and Minikube clusters named "{cluster_name}" exist. ' + "Please specify --kind or --minikube to choose which one to delete." + ) + ) + elif kind_exists: + return "kind" + elif minikube_exists: + return "minikube" + else: + return None + + +def auto_detect_cluster_type() -> Literal["kind"] | Literal["minikube"]: + """Auto-detect available cluster type, preferring Kind over Minikube.""" + if kind_installed("kind"): + return "kind" + elif minikube_installed("minikube"): + return "minikube" + else: + raise ToolNotInstalledError( + "kind or minikube", + "Neither Kind nor Minikube is installed. Please install one of them:\n" + " • Kind: https://kind.sigs.k8s.io/docs/user/quick-start/\n" + " • Minikube: https://minikube.sigs.k8s.io/docs/start/" + ) + + +async def detect_cluster_type(context_name: str, server_url: str, minikube: str = "minikube") -> str: + """Detect if cluster is Kind, Minikube, or Remote.""" + # Check for Kind cluster + if "kind-" in context_name or context_name.startswith("kind"): + return "kind" + + # Check for minikube in context name first + if "minikube" in context_name.lower(): + return "minikube" + + # Check for localhost/127.0.0.1 which usually indicates Kind + if any(host in server_url.lower() for host in ["localhost", "127.0.0.1", "0.0.0.0"]): + return "kind" + + # Check for minikube VM IP ranges (192.168.x.x, 172.x.x.x) and typical minikube ports + minikube_pattern_1 = re.search(r"192\.168\.\d+\.\d+:(8443|443)", server_url) + minikube_pattern_2 = re.search(r"172\.\d+\.\d+\.\d+:(8443|443)", server_url) + if minikube_pattern_1 or minikube_pattern_2: + # Try to verify it's actually minikube by checking if any minikube cluster exists + try: + # Get list of minikube profiles + cmd = [minikube, "profile", "list", "-o", "json"] + returncode, stdout, _ = await run_command(cmd) + if returncode == 0: + try: + profiles = json.loads(stdout) + # If we have any valid minikube profiles, this is likely minikube + if profiles.get("valid") and len(profiles["valid"]) > 0: + return "minikube" + except (json.JSONDecodeError, KeyError): + pass + except RuntimeError: + pass + + # Everything else is remote + return "remote" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection_test.py new file mode 100644 index 000000000..eea8a78ba --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection_test.py @@ -0,0 +1,360 @@ +"""Tests for cluster detection and type identification.""" + +from unittest.mock import patch + +import pytest + +from jumpstarter_kubernetes.cluster.detection import ( + auto_detect_cluster_type, + detect_cluster_type, + detect_container_runtime, + detect_existing_cluster_type, + detect_kind_provider, +) + + +class TestDetectContainerRuntime: + """Test container runtime detection.""" + + @patch("shutil.which") + def test_detect_container_runtime_docker(self, mock_which): + mock_which.side_effect = lambda cmd: "/usr/bin/docker" if cmd == "docker" else None + result = detect_container_runtime() + assert result == "docker" + + @patch("shutil.which") + def test_detect_container_runtime_podman(self, mock_which): + mock_which.side_effect = lambda cmd: "/usr/bin/podman" if cmd == "podman" else None + result = detect_container_runtime() + assert result == "podman" + + @patch("shutil.which") + def test_detect_container_runtime_nerdctl(self, mock_which): + mock_which.side_effect = lambda cmd: "/usr/bin/nerdctl" if cmd == "nerdctl" else None + result = detect_container_runtime() + assert result == "nerdctl" + + @patch("shutil.which") + def test_detect_container_runtime_none_available(self, mock_which): + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError + + mock_which.return_value = None + with pytest.raises( + ToolNotInstalledError, + match="No supported container runtime found in PATH. Kind requires docker, podman, or nerdctl.", + ): + detect_container_runtime() + + @patch("shutil.which") + def test_detect_container_runtime_docker_preferred(self, mock_which): + # Docker should be preferred when multiple are available + mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}" if cmd in ["docker", "podman"] else None + result = detect_container_runtime() + assert result == "docker" + + +class TestDetectKindProvider: + """Test Kind provider detection.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.detect_container_runtime") + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_kind_provider_control_plane(self, mock_run_command, mock_detect_runtime): + mock_detect_runtime.return_value = "docker" + mock_run_command.return_value = (0, "", "") + + runtime, node_name = await detect_kind_provider("test-cluster") + + assert runtime == "docker" + assert node_name == "test-cluster-control-plane" + mock_run_command.assert_called_once_with(["docker", "inspect", "test-cluster-control-plane"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.detect_container_runtime") + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_kind_provider_kind_prefix(self, mock_run_command, mock_detect_runtime): + mock_detect_runtime.return_value = "docker" + # First call fails, second succeeds + mock_run_command.side_effect = [(1, "", ""), (0, "", "")] + + runtime, node_name = await detect_kind_provider("test-cluster") + + assert runtime == "docker" + assert node_name == "kind-test-cluster-control-plane" + assert mock_run_command.call_count == 2 + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.detect_container_runtime") + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_kind_provider_default_cluster(self, mock_run_command, mock_detect_runtime): + mock_detect_runtime.return_value = "docker" + mock_run_command.return_value = (0, "", "") + + runtime, node_name = await detect_kind_provider("kind") + + assert runtime == "docker" + assert node_name == "kind-control-plane" + mock_run_command.assert_called_once_with(["docker", "inspect", "kind-control-plane"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.detect_container_runtime") + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_kind_provider_fallback(self, mock_run_command, mock_detect_runtime): + mock_detect_runtime.return_value = "podman" + mock_run_command.return_value = (1, "", "") # All checks fail + + runtime, node_name = await detect_kind_provider("test-cluster") + + assert runtime == "podman" + assert node_name == "test-cluster-control-plane" # Fallback + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.detect_container_runtime") + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_kind_provider_runtime_error(self, mock_run_command, mock_detect_runtime): + mock_detect_runtime.return_value = "docker" + mock_run_command.side_effect = RuntimeError("Command failed") + + runtime, node_name = await detect_kind_provider("test-cluster") + + assert runtime == "docker" + assert node_name == "test-cluster-control-plane" # Fallback + + +class TestDetectExistingClusterType: + """Test detection of existing cluster types.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.detection.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_cluster_exists") + async def test_detect_existing_cluster_type_kind_only( + self, mock_minikube_exists, mock_kind_exists, mock_minikube_installed, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = True + mock_kind_exists.return_value = True + mock_minikube_exists.return_value = False + + result = await detect_existing_cluster_type("test-cluster") + + assert result == "kind" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.detection.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_cluster_exists") + async def test_detect_existing_cluster_type_minikube_only( + self, mock_minikube_exists, mock_kind_exists, mock_minikube_installed, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = True + mock_kind_exists.return_value = False + mock_minikube_exists.return_value = True + + result = await detect_existing_cluster_type("test-cluster") + + assert result == "minikube" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.detection.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_cluster_exists") + async def test_detect_existing_cluster_type_both_exist( + self, mock_minikube_exists, mock_kind_exists, mock_minikube_installed, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = True + mock_kind_exists.return_value = True + mock_minikube_exists.return_value = True + + from jumpstarter_kubernetes.exceptions import ClusterOperationError + + with pytest.raises( + ClusterOperationError, + match='Both Kind and Minikube clusters named "test-cluster" exist', + ): + await detect_existing_cluster_type("test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.detection.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_cluster_exists") + async def test_detect_existing_cluster_type_none_exist( + self, mock_minikube_exists, mock_kind_exists, mock_minikube_installed, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = True + mock_kind_exists.return_value = False + mock_minikube_exists.return_value = False + + result = await detect_existing_cluster_type("test-cluster") + + assert result is None + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + async def test_detect_existing_cluster_type_kind_not_installed(self, mock_minikube_installed, mock_kind_installed): + mock_kind_installed.return_value = False + mock_minikube_installed.return_value = True + + result = await detect_existing_cluster_type("test-cluster") + + assert result is None + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.detection.kind_cluster_exists") + async def test_detect_existing_cluster_type_runtime_error( + self, mock_kind_exists, mock_minikube_installed, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = False + mock_kind_exists.side_effect = RuntimeError("Command failed") + + result = await detect_existing_cluster_type("test-cluster") + + assert result is None + + +class TestAutoDetectClusterType: + """Test automatic cluster type detection.""" + + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + def test_auto_detect_cluster_type_kind_available(self, mock_minikube_installed, mock_kind_installed): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = False + + result = auto_detect_cluster_type() + + assert result == "kind" + + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + def test_auto_detect_cluster_type_minikube_only(self, mock_minikube_installed, mock_kind_installed): + mock_kind_installed.return_value = False + mock_minikube_installed.return_value = True + + result = auto_detect_cluster_type() + + assert result == "minikube" + + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + def test_auto_detect_cluster_type_kind_preferred(self, mock_minikube_installed, mock_kind_installed): + mock_kind_installed.return_value = True + mock_minikube_installed.return_value = True + + result = auto_detect_cluster_type() + + assert result == "kind" # Kind is preferred + + @patch("jumpstarter_kubernetes.cluster.detection.kind_installed") + @patch("jumpstarter_kubernetes.cluster.detection.minikube_installed") + def test_auto_detect_cluster_type_none_available(self, mock_minikube_installed, mock_kind_installed): + mock_kind_installed.return_value = False + mock_minikube_installed.return_value = False + + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError + + with pytest.raises( + ToolNotInstalledError, + match="Neither Kind nor Minikube is installed", + ): + auto_detect_cluster_type() + + +class TestDetectClusterType: + """Test cluster type detection from context and server URL.""" + + @pytest.mark.asyncio + async def test_detect_cluster_type_kind_context_prefix(self): + result = await detect_cluster_type("kind-test-cluster", "https://127.0.0.1:6443") + assert result == "kind" + + @pytest.mark.asyncio + async def test_detect_cluster_type_kind_context_name(self): + result = await detect_cluster_type("kind", "https://127.0.0.1:6443") + assert result == "kind" + + @pytest.mark.asyncio + async def test_detect_cluster_type_minikube_context(self): + result = await detect_cluster_type("minikube", "https://192.168.49.2:8443") + assert result == "minikube" + + @pytest.mark.asyncio + async def test_detect_cluster_type_localhost(self): + result = await detect_cluster_type("local-cluster", "https://localhost:6443") + assert result == "kind" + + @pytest.mark.asyncio + async def test_detect_cluster_type_127_0_0_1(self): + result = await detect_cluster_type("local-cluster", "https://127.0.0.1:6443") + assert result == "kind" + + @pytest.mark.asyncio + async def test_detect_cluster_type_0_0_0_0(self): + result = await detect_cluster_type("local-cluster", "https://0.0.0.0:6443") + assert result == "kind" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_cluster_type_minikube_ip_range_192(self, mock_run_command): + mock_run_command.return_value = (0, '{"valid": [{"Name": "test"}]}', "") + + result = await detect_cluster_type("test-cluster", "https://192.168.49.2:8443") + + assert result == "minikube" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_cluster_type_minikube_ip_range_172(self, mock_run_command): + mock_run_command.return_value = (0, '{"valid": [{"Name": "test"}]}', "") + + result = await detect_cluster_type("test-cluster", "https://172.17.0.2:443") + + assert result == "minikube" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_cluster_type_minikube_ip_no_profiles(self, mock_run_command): + mock_run_command.return_value = (1, "", "error") + + result = await detect_cluster_type("test-cluster", "https://192.168.49.2:8443") + + assert result == "remote" # Falls back to remote if no minikube profiles + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_cluster_type_minikube_invalid_json(self, mock_run_command): + mock_run_command.return_value = (0, "invalid json", "") + + result = await detect_cluster_type("test-cluster", "https://192.168.49.2:8443") + + assert result == "remote" # Falls back to remote if JSON parsing fails + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.detection.run_command") + async def test_detect_cluster_type_minikube_runtime_error(self, mock_run_command): + mock_run_command.side_effect = RuntimeError("Command failed") + + result = await detect_cluster_type("test-cluster", "https://192.168.49.2:8443") + + assert result == "remote" # Falls back to remote if command fails + + @pytest.mark.asyncio + async def test_detect_cluster_type_remote(self): + result = await detect_cluster_type("production-cluster", "https://k8s.example.com:443") + assert result == "remote" + + @pytest.mark.asyncio + async def test_detect_cluster_type_custom_minikube_binary(self): + result = await detect_cluster_type("test-cluster", "https://example.com", minikube="custom-minikube") + assert result == "remote" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py new file mode 100644 index 000000000..079407833 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py @@ -0,0 +1,46 @@ +"""Endpoint configuration for cluster management.""" + +from typing import Optional + +from ..exceptions import EndpointConfigurationError, ToolNotInstalledError +from .minikube import minikube_installed +from jumpstarter.common.ipaddr import get_ip_address, get_minikube_ip + + +async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_name: str) -> str: + """Get IP address for the cluster.""" + if cluster_type == "minikube": + if not minikube_installed(minikube): + raise ToolNotInstalledError("minikube") + try: + ip = await get_minikube_ip(cluster_name, minikube) + except Exception as e: + raise EndpointConfigurationError(f"Could not determine Minikube IP address.\n{e}", "minikube") from e + else: + ip = get_ip_address() + if ip == "0.0.0.0": + raise EndpointConfigurationError("Could not determine IP address, use --ip to specify an IP address") + + return ip + + +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]: + """Configure endpoints for Jumpstarter installation.""" + 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 diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints_test.py new file mode 100644 index 000000000..91f003574 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints_test.py @@ -0,0 +1,270 @@ +"""Tests for endpoint configuration functionality.""" + +from unittest.mock import patch + +import pytest + +from jumpstarter_kubernetes.cluster.endpoints import configure_endpoints, get_ip_generic +from jumpstarter_kubernetes.exceptions import EndpointConfigurationError + + +class TestGetIpGeneric: + """Test generic IP address retrieval.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.endpoints.get_minikube_ip") + async def test_get_ip_generic_minikube_success(self, 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") + + assert result == "192.168.49.2" + mock_get_minikube_ip.assert_called_once_with("test-cluster", "minikube") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.minikube_installed") + async def test_get_ip_generic_minikube_not_installed(self, mock_minikube_installed): + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError + + mock_minikube_installed.return_value = False + + with pytest.raises(ToolNotInstalledError, match="minikube is not installed"): + await get_ip_generic("minikube", "minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.endpoints.get_minikube_ip") + async def test_get_ip_generic_minikube_ip_error(self, mock_get_minikube_ip, mock_minikube_installed): + + mock_minikube_installed.return_value = True + mock_get_minikube_ip.side_effect = Exception("IP detection failed") + + with pytest.raises(EndpointConfigurationError, match="Could not determine Minikube IP address"): + await get_ip_generic("minikube", "minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_address") + async def test_get_ip_generic_kind_success(self, mock_get_ip_address): + mock_get_ip_address.return_value = "10.0.0.100" + + result = await get_ip_generic("kind", "minikube", "test-cluster") + + assert result == "10.0.0.100" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_address") + async def test_get_ip_generic_kind_zero_ip(self, mock_get_ip_address): + + mock_get_ip_address.return_value = "0.0.0.0" + + with pytest.raises(EndpointConfigurationError, match="Could not determine IP address"): + await get_ip_generic("kind", "minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_address") + async def test_get_ip_generic_none_cluster_type(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" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_address") + async def test_get_ip_generic_other_cluster_type(self, mock_get_ip_address): + mock_get_ip_address.return_value = "172.16.0.50" + + result = await get_ip_generic("remote", "minikube", "test-cluster") + + assert result == "172.16.0.50" + + +class TestConfigureEndpoints: + """Test endpoint configuration.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_all_provided(self, mock_get_ip_generic): + # When all parameters are provided, get_ip_generic should not be called + result = await configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip="10.0.0.100", + basedomain="test.example.com", + grpc_endpoint="grpc.test.example.com:9000", + router_endpoint="router.test.example.com:9001", + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "10.0.0.100" + assert basedomain == "test.example.com" + assert grpc_endpoint == "grpc.test.example.com:9000" + assert router_endpoint == "router.test.example.com:9001" + mock_get_ip_generic.assert_not_called() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_no_ip_provided(self, mock_get_ip_generic): + mock_get_ip_generic.return_value = "192.168.49.2" + + result = await configure_endpoints( + cluster_type="minikube", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain="test.example.com", + grpc_endpoint="grpc.test.example.com:9000", + router_endpoint="router.test.example.com:9001", + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "192.168.49.2" + assert basedomain == "test.example.com" + assert grpc_endpoint == "grpc.test.example.com:9000" + assert router_endpoint == "router.test.example.com:9001" + mock_get_ip_generic.assert_called_once_with("minikube", "minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_no_basedomain_provided(self, mock_get_ip_generic): + mock_get_ip_generic.return_value = "10.0.0.100" + + result = await configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain=None, + grpc_endpoint="grpc.test.example.com:9000", + router_endpoint="router.test.example.com:9001", + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "10.0.0.100" + assert basedomain == "jumpstarter.10.0.0.100.nip.io" + assert grpc_endpoint == "grpc.test.example.com:9000" + assert router_endpoint == "router.test.example.com:9001" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_no_grpc_endpoint_provided(self, mock_get_ip_generic): + mock_get_ip_generic.return_value = "10.0.0.100" + + result = await configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain=None, + grpc_endpoint=None, + router_endpoint="router.test.example.com:9001", + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "10.0.0.100" + assert basedomain == "jumpstarter.10.0.0.100.nip.io" + assert grpc_endpoint == "grpc.jumpstarter.10.0.0.100.nip.io:8082" + assert router_endpoint == "router.test.example.com:9001" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_no_router_endpoint_provided(self, mock_get_ip_generic): + mock_get_ip_generic.return_value = "10.0.0.100" + + result = await configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain=None, + grpc_endpoint=None, + router_endpoint=None, + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "10.0.0.100" + assert basedomain == "jumpstarter.10.0.0.100.nip.io" + assert grpc_endpoint == "grpc.jumpstarter.10.0.0.100.nip.io:8082" + assert router_endpoint == "router.jumpstarter.10.0.0.100.nip.io:8083" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_all_defaults(self, mock_get_ip_generic): + mock_get_ip_generic.return_value = "192.168.1.50" + + result = await configure_endpoints( + cluster_type="minikube", + minikube="minikube", + cluster_name="my-cluster", + ip=None, + basedomain=None, + grpc_endpoint=None, + router_endpoint=None, + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "192.168.1.50" + assert basedomain == "jumpstarter.192.168.1.50.nip.io" + assert grpc_endpoint == "grpc.jumpstarter.192.168.1.50.nip.io:8082" + assert router_endpoint == "router.jumpstarter.192.168.1.50.nip.io:8083" + mock_get_ip_generic.assert_called_once_with("minikube", "minikube", "my-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_custom_basedomain_with_defaults(self, mock_get_ip_generic): + mock_get_ip_generic.return_value = "172.16.0.1" + + result = await configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain="custom.domain.io", + grpc_endpoint=None, + router_endpoint=None, + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "172.16.0.1" + assert basedomain == "custom.domain.io" + assert grpc_endpoint == "grpc.custom.domain.io:8082" + assert router_endpoint == "router.custom.domain.io:8083" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_ip_provided_no_auto_detection(self, mock_get_ip_generic): + result = await configure_endpoints( + cluster_type="kind", + minikube="minikube", + cluster_name="test-cluster", + ip="192.168.100.50", + basedomain=None, + grpc_endpoint=None, + router_endpoint=None, + ) + + ip, basedomain, grpc_endpoint, router_endpoint = result + assert ip == "192.168.100.50" + assert basedomain == "jumpstarter.192.168.100.50.nip.io" + assert grpc_endpoint == "grpc.jumpstarter.192.168.100.50.nip.io:8082" + assert router_endpoint == "router.jumpstarter.192.168.100.50.nip.io:8083" + mock_get_ip_generic.assert_not_called() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.endpoints.get_ip_generic") + async def test_configure_endpoints_ip_detection_error_propagates(self, mock_get_ip_generic): + mock_get_ip_generic.side_effect = EndpointConfigurationError("IP detection failed") + + with pytest.raises(EndpointConfigurationError, match="IP detection failed"): + await configure_endpoints( + cluster_type="minikube", + minikube="minikube", + cluster_name="test-cluster", + ip=None, + basedomain=None, + grpc_endpoint=None, + router_endpoint=None, + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py new file mode 100644 index 000000000..0b3c6b903 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm.py @@ -0,0 +1,53 @@ +"""Helm chart management operations.""" + +from typing import Optional + +from ..callbacks import OutputCallback, SilentCallback +from ..install import install_helm_chart + + +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, + callback: OutputCallback = None, + values_files: Optional[list[str]] = None, +) -> None: + """Install Jumpstarter Helm chart.""" + if callback is None: + callback = SilentCallback() + + callback.progress(f'Installing Jumpstarter service v{version} in namespace "{namespace}" with Helm\n') + callback.progress(f"Chart URI: {chart}") + callback.progress(f"Chart Version: {version}") + callback.progress(f"IP Address: {ip}") + callback.progress(f"Basedomain: {basedomain}") + callback.progress(f"Service Endpoint: {grpc_endpoint}") + callback.progress(f"Router Endpoint: {router_endpoint}") + callback.progress(f"gRPC Mode: {mode}\n") + + await install_helm_chart( + chart, + name, + namespace, + basedomain, + grpc_endpoint, + router_endpoint, + mode, + version, + kubeconfig, + context, + helm, + values_files, + ) + + callback.success(f'Installed Helm release "{name}" in namespace "{namespace}"') diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py new file mode 100644 index 000000000..3dce9f60d --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/helm_test.py @@ -0,0 +1,243 @@ +"""Tests for Helm chart management operations.""" + +from unittest.mock import patch + +import pytest + +from jumpstarter_kubernetes.cluster.helm import install_jumpstarter_helm_chart + + +class TestInstallJumpstarterHelmChart: + """Test Jumpstarter Helm chart installation.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_all_params(self, mock_install_helm_chart): + from unittest.mock import MagicMock + + mock_install_helm_chart.return_value = None + mock_callback = MagicMock() + + await install_jumpstarter_helm_chart( + chart="oci://registry.example.com/jumpstarter", + name="jumpstarter", + namespace="jumpstarter-system", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="insecure", + version="1.0.0", + kubeconfig="/path/to/kubeconfig", + context="test-context", + helm="helm", + ip="192.168.1.100", + callback=mock_callback, + ) + + # Verify that install_helm_chart was called with correct parameters + mock_install_helm_chart.assert_called_once_with( + "oci://registry.example.com/jumpstarter", + "jumpstarter", + "jumpstarter-system", + "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", + "insecure", + "1.0.0", + "/path/to/kubeconfig", + "test-context", + "helm", + None, + ) + + # Verify callback was called + assert mock_callback.progress.call_count >= 7 # Multiple progress messages + assert mock_callback.success.call_count == 1 # One success message + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_with_none_values(self, mock_install_helm_chart): + mock_install_helm_chart.return_value = None + + await install_jumpstarter_helm_chart( + chart="jumpstarter/jumpstarter", + name="my-jumpstarter", + namespace="default", + basedomain="test.example.com", + grpc_endpoint="grpc.test.example.com:443", + router_endpoint="router.test.example.com:443", + mode="secure", + version="2.1.0", + kubeconfig=None, + context=None, + helm="helm3", + ip="10.0.0.1", + ) + + # Verify that install_helm_chart was called with None values preserved + mock_install_helm_chart.assert_called_once_with( + "jumpstarter/jumpstarter", + "my-jumpstarter", + "default", + "test.example.com", + "grpc.test.example.com:443", + "router.test.example.com:443", + "secure", + "2.1.0", + None, + None, + "helm3", + None, + ) + + # Verify success message with correct values + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_secure_mode(self, mock_install_helm_chart): + mock_install_helm_chart.return_value = None + + await install_jumpstarter_helm_chart( + chart="https://example.com/charts/jumpstarter-1.5.0.tgz", + name="production-jumpstarter", + namespace="production", + basedomain="jumpstarter.prod.example.com", + grpc_endpoint="grpc.jumpstarter.prod.example.com:443", + router_endpoint="router.jumpstarter.prod.example.com:443", + mode="secure", + version="1.5.0", + kubeconfig="/etc/kubernetes/admin.conf", + context="production-cluster", + helm="/usr/local/bin/helm", + ip="203.0.113.1", + ) + + # Verify gRPC mode is correctly displayed + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_custom_endpoints(self, mock_install_helm_chart): + mock_install_helm_chart.return_value = None + + await install_jumpstarter_helm_chart( + chart="jumpstarter", + name="dev-jumpstarter", + namespace="development", + basedomain="dev.local", + grpc_endpoint="grpc-custom.dev.local:9090", + router_endpoint="router-custom.dev.local:9091", + mode="insecure", + version="0.9.0-beta", + kubeconfig="~/.kube/config", + context="dev-context", + helm="helm", + ip="172.16.0.10", + ) + + # Verify custom endpoints are displayed correctly + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_install_helm_chart_error(self, mock_install_helm_chart): + # Test that exceptions from install_helm_chart propagate + mock_install_helm_chart.side_effect = Exception("Helm installation failed") + + with pytest.raises(Exception, match="Helm installation failed"): + await install_jumpstarter_helm_chart( + chart="jumpstarter", + name="test-jumpstarter", + namespace="test", + basedomain="test.local", + grpc_endpoint="grpc.test.local:8082", + router_endpoint="router.test.local:8083", + mode="insecure", + version="1.0.0", + kubeconfig=None, + context=None, + helm="helm", + ip="192.168.1.1", + ) + + # Exception was raised correctly - test complete + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_minimal_params(self, mock_install_helm_chart): + mock_install_helm_chart.return_value = None + + await install_jumpstarter_helm_chart( + chart="minimal", + name="min", + namespace="min-ns", + basedomain="min.io", + grpc_endpoint="grpc.min.io:80", + router_endpoint="router.min.io:80", + mode="test", + version="0.1.0", + kubeconfig=None, + context=None, + helm="h", + ip="1.1.1.1", + ) + + # Verify all required parameters work with minimal values + mock_install_helm_chart.assert_called_once_with( + "minimal", + "min", + "min-ns", + "min.io", + "grpc.min.io:80", + "router.min.io:80", + "test", + "0.1.0", + None, + None, + "h", + None, + ) + + # Verify appropriate echo calls were made + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.helm.install_helm_chart") + async def test_install_jumpstarter_helm_chart_with_values_files(self, mock_install_helm_chart): + """Test that values_files parameter is passed through correctly.""" + from unittest.mock import MagicMock + + mock_install_helm_chart.return_value = None + mock_callback = MagicMock() + + values_files = ["/path/to/values1.yaml", "/path/to/values2.yaml"] + + await install_jumpstarter_helm_chart( + chart="oci://registry.example.com/jumpstarter", + name="jumpstarter", + namespace="jumpstarter-system", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="insecure", + version="1.0.0", + kubeconfig="/path/to/kubeconfig", + context="test-context", + helm="helm", + ip="192.168.1.100", + callback=mock_callback, + values_files=values_files, + ) + + # Verify that install_helm_chart was called with values_files + mock_install_helm_chart.assert_called_once_with( + "oci://registry.example.com/jumpstarter", + "jumpstarter", + "jumpstarter-system", + "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", + "insecure", + "1.0.0", + "/path/to/kubeconfig", + "test-context", + "helm", + values_files, + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py new file mode 100644 index 000000000..aa7d40681 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py @@ -0,0 +1,231 @@ +"""Kind cluster management operations.""" + +import os +import shlex +import shutil +import tempfile +from typing import List, Optional + +from ..callbacks import OutputCallback, SilentCallback +from ..exceptions import ( + CertificateError, + ClusterAlreadyExistsError, + ClusterOperationError, + ToolNotInstalledError, +) +from .common import run_command, run_command_with_output + + +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 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_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_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 ClusterAlreadyExistsError(cluster_name, "kind") + 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 # ingress controller + hostPort: 5080 + protocol: TCP + - containerPort: 30010 # grpc nodeport + hostPort: 8082 + protocol: TCP + - containerPort: 30011 # grpc router nodeport + hostPort: 8083 + protocol: TCP + - containerPort: 32000 # dex nodeport + hostPort: 5556 + protocol: TCP + - containerPort: 443 + hostPort: 5443 + protocol: TCP +""" + + # Write the cluster config to a temporary file + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(cluster_config) + config_file = f.name + + try: + command = [kind, "create", "cluster", "--name", cluster_name, "--config", config_file] + command.extend(extra_args) + + returncode = await run_command_with_output(command) + + if returncode == 0: + return True + else: + raise RuntimeError(f"Failed to create Kind cluster '{cluster_name}'") + finally: + # Clean up the temporary config file + try: + os.unlink(config_file) + except OSError: + pass + + +async def list_kind_clusters(kind: str) -> List[str]: + """List all Kind clusters.""" + if not kind_installed(kind): + return [] + + try: + returncode, stdout, _ = await run_command([kind, "get", "clusters"]) + if returncode == 0: + clusters = [line.strip() for line in stdout.split("\n") if line.strip()] + return clusters + return [] + except RuntimeError: + return [] + + +async def inject_certificates(extra_certs: str, cluster_name: str, callback: OutputCallback = None) -> None: + """Inject custom certificates into a Kind cluster.""" + if callback is None: + callback = SilentCallback() + + # Expand ~ and environment variables before making absolute + expanded_path = os.path.expanduser(os.path.expandvars(extra_certs)) + extra_certs_path = os.path.abspath(expanded_path) + + if not os.path.exists(extra_certs_path): + raise CertificateError(f"Extra certificates file not found: {extra_certs_path}", extra_certs_path) + + # Detect Kind provider info + from .detection import detect_kind_provider + + runtime, node_name = await detect_kind_provider(cluster_name) + + callback.progress(f"Injecting certificates from {extra_certs_path} into Kind cluster...") + + # Copy certificates into the Kind node + copy_cmd = [runtime, "cp", extra_certs_path, f"{node_name}:/usr/local/share/ca-certificates/extra-certs.crt"] + + returncode = await run_command_with_output(copy_cmd) + + if returncode != 0: + raise CertificateError(f"Failed to copy certificates to Kind node: {node_name}") + + # Update ca-certificates in the node + update_cmd = [runtime, "exec", node_name, "update-ca-certificates"] + + returncode = await run_command_with_output(update_cmd) + + if returncode != 0: + raise CertificateError("Failed to update certificates in Kind node") + + callback.success("Successfully injected custom certificates into Kind cluster") + + +async def create_kind_cluster_with_options( + kind: str, + cluster_name: str, + kind_extra_args: str, + force_recreate_cluster: bool, + extra_certs: Optional[str] = None, + callback: OutputCallback = None, +) -> None: + """Create a Kind cluster with optional certificate injection.""" + if callback is None: + callback = SilentCallback() + + if not kind_installed(kind): + raise ToolNotInstalledError("kind") + + cluster_action = "Recreating" if force_recreate_cluster else "Creating" + callback.progress(f'{cluster_action} Kind cluster "{cluster_name}"...') + extra_args_list = shlex.split(kind_extra_args) if kind_extra_args.strip() else [] + + try: + await create_kind_cluster(kind, cluster_name, extra_args_list, force_recreate_cluster) + + # Inject custom certificates if provided + if extra_certs: + await inject_certificates(extra_certs, cluster_name, callback) + + except ClusterAlreadyExistsError as e: + if not force_recreate_cluster: + callback.progress(f'Kind cluster "{cluster_name}" already exists, continuing...') + # Still inject certificates if cluster exists and extra_certs provided + if extra_certs: + await inject_certificates(extra_certs, cluster_name, callback) + else: + raise ClusterOperationError("recreate", cluster_name, "kind", e) from e + except Exception as e: + action = "recreate" if force_recreate_cluster else "create" + raise ClusterOperationError(action, cluster_name, "kind", e) from e + + +async def delete_kind_cluster_with_feedback(kind: str, cluster_name: str, callback: OutputCallback = None) -> None: + """Delete a Kind cluster with user feedback.""" + if callback is None: + callback = SilentCallback() + + if not kind_installed(kind): + raise ToolNotInstalledError("kind") + + try: + await delete_kind_cluster(kind, cluster_name) + except Exception as e: + raise ClusterOperationError("delete", cluster_name, "kind", e) from e diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py new file mode 100644 index 000000000..a26df7102 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py @@ -0,0 +1,273 @@ +"""Tests for Kind cluster management operations.""" + +from unittest.mock import patch + +import pytest + +from jumpstarter_kubernetes.cluster.kind import ( + create_kind_cluster, + delete_kind_cluster, + kind_cluster_exists, + kind_installed, +) +from jumpstarter_kubernetes.exceptions import ClusterAlreadyExistsError + + +class TestKindInstalled: + """Test Kind installation detection.""" + + @patch("jumpstarter_kubernetes.cluster.kind.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.kind.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.kind.shutil.which") + def test_kind_installed_custom_binary(self, mock_which): + mock_which.return_value = "/custom/path/kind" + assert kind_installed("custom-kind") is True + mock_which.assert_called_once_with("custom-kind") + + +class TestKindClusterExists: + """Test Kind cluster existence checking.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.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.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.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.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.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.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.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.run_command") + async def test_kind_cluster_exists_custom_binary(self, mock_run_command, mock_kind_installed): + mock_kind_installed.return_value = True + mock_run_command.return_value = (0, "", "") + + result = await kind_cluster_exists("custom-kind", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["custom-kind", "get", "kubeconfig", "--name", "test-cluster"]) + + +class TestCreateKindCluster: + """Test Kind cluster creation.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_create_kind_cluster_success(self, mock_run_command, mock_cluster_exists, mock_kind_installed): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 0 + + result = await create_kind_cluster("kind", "test-cluster") + + assert result is True + mock_run_command.assert_called_once() + args = mock_run_command.call_args[0][0] + assert args[0] == "kind" + assert args[1] == "create" + assert args[2] == "cluster" + assert "--name" in args + assert "test-cluster" in args + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.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.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.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(ClusterAlreadyExistsError) as exc_info: + await create_kind_cluster("kind", "test-cluster") + + assert exc_info.value.cluster_name == "test-cluster" + assert exc_info.value.cluster_type == "kind" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.delete_kind_cluster") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_create_kind_cluster_force_recreate( + self, mock_run_command, 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_run_command.return_value = 0 + + 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.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_create_kind_cluster_with_extra_args( + self, mock_run_command, mock_cluster_exists, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 0 + + result = await create_kind_cluster("kind", "test-cluster", extra_args=["--verbosity=1"]) + + assert result is True + args = mock_run_command.call_args[0][0] + assert "--verbosity=1" in args + + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_create_kind_cluster_command_failure( + self, mock_run_command, mock_cluster_exists, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 1 + + with pytest.raises(RuntimeError, match="Failed to create Kind cluster 'test-cluster'"): + await create_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_create_kind_cluster_custom_binary( + self, mock_run_command, mock_cluster_exists, mock_kind_installed + ): + mock_kind_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 0 + + result = await create_kind_cluster("custom-kind", "test-cluster") + + assert result is True + args = mock_run_command.call_args[0][0] + assert args[0] == "custom-kind" + + +class TestDeleteKindCluster: + """Test Kind cluster deletion.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.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.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.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.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 + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_delete_kind_cluster_command_failure( + 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 = 1 + + with pytest.raises(RuntimeError, match="Failed to delete Kind cluster 'test-cluster'"): + await delete_kind_cluster("kind", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") + @patch("jumpstarter_kubernetes.cluster.kind.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.kind.run_command_with_output") + async def test_delete_kind_cluster_custom_binary( + 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("custom-kind", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["custom-kind", "delete", "cluster", "--name", "test-cluster"]) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py new file mode 100644 index 000000000..001c1aef1 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -0,0 +1,321 @@ +"""Kubectl operations for cluster management.""" + +import json +from typing import Dict, List, Optional + +from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance +from .common import run_command + + +async def check_kubernetes_access(context: Optional[str] = None, kubectl: str = "kubectl") -> bool: + """Check if Kubernetes cluster is accessible.""" + try: + cmd = [kubectl] + if context: + cmd.extend(["--context", context]) + cmd.extend(["cluster-info", "--request-timeout=5s"]) + + returncode, _, _ = await run_command(cmd) + return returncode == 0 + except RuntimeError: + return False + + +async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[Dict[str, str]]: + """Get all kubectl contexts.""" + contexts = [] + + try: + cmd = [kubectl, "config", "view", "-o", "json"] + returncode, stdout, stderr = await run_command(cmd) + + if returncode != 0: + from ..exceptions import KubeconfigError + raise KubeconfigError(f"Failed to get kubectl config: {stderr}") + + config = json.loads(stdout) + + current_context = config.get("current-context", "") + context_list = config.get("contexts", []) + + for ctx in context_list: + context_name = ctx.get("name", "") + cluster_name = ctx.get("context", {}).get("cluster", "") + user_name = ctx.get("context", {}).get("user", "") + namespace = ctx.get("context", {}).get("namespace") or "default" + + # Get cluster server URL + server_url = "" + for cluster in config.get("clusters", []): + if cluster.get("name") == cluster_name: + server_url = cluster.get("cluster", {}).get("server", "") + break + + contexts.append( + { + "name": context_name, + "cluster": cluster_name, + "server": server_url, + "user": user_name, + "namespace": namespace, + "current": context_name == current_context, + } + ) + + return contexts + + except json.JSONDecodeError as e: + from ..exceptions import KubeconfigError + raise KubeconfigError(f"Failed to parse kubectl config: {e}") from e + except Exception as e: + from ..exceptions import KubeconfigError + raise KubeconfigError(f"Error listing kubectl contexts: {e}") from e + + +async def check_jumpstarter_installation( # noqa: C901 + context: str, namespace: Optional[str] = None, helm: str = "helm", kubectl: str = "kubectl" +) -> V1Alpha1JumpstarterInstance: + """Check if Jumpstarter is installed in the cluster.""" + result_data = { + "installed": False, + "version": None, + "namespace": None, + "chart_name": None, + "status": None, + "has_crds": False, + "error": None, + "basedomain": None, + "controller_endpoint": None, + "router_endpoint": None, + } + + try: + # Check for Helm installation first + helm_cmd = [helm, "list", "--all-namespaces", "-o", "json", "--kube-context", context] + returncode, stdout, _ = await run_command(helm_cmd) + + if returncode == 0: + # Extract JSON from output (handle case where warnings are printed before JSON) + json_start = stdout.find("[") + if json_start >= 0: + json_output = stdout[json_start:] + releases = json.loads(json_output) + else: + releases = json.loads(stdout) # Fallback to original parsing + for release in releases: + # Look for Jumpstarter chart + if "jumpstarter" in release.get("chart", "").lower(): + result_data["installed"] = True + result_data["version"] = release.get("app_version") or release.get("chart", "").split("-")[-1] + result_data["namespace"] = release.get("namespace") + result_data["chart_name"] = release.get("name") + result_data["status"] = release.get("status") + + # Try to get Helm values to extract basedomain and endpoints + try: + values_cmd = [ + helm, + "get", + "values", + release.get("name"), + "-n", + release.get("namespace"), + "-o", + "json", + "--kube-context", + context, + ] + values_returncode, values_stdout, _ = await run_command(values_cmd) + + if values_returncode == 0: + # Extract JSON from values output (handle warnings) + json_start = values_stdout.find("{") + if json_start >= 0: + json_output = values_stdout[json_start:] + values = json.loads(json_output) + else: + values = json.loads(values_stdout) # Fallback + + # Extract basedomain + basedomain = values.get("global", {}).get("baseDomain") + if basedomain: + result_data["basedomain"] = basedomain + # Construct default endpoints from basedomain + result_data["controller_endpoint"] = f"grpc.{basedomain}:8082" + result_data["router_endpoint"] = f"router.{basedomain}:8083" + + # Check for explicit endpoints in values + controller_config = values.get("jumpstarter-controller", {}).get("grpc", {}) + if controller_config.get("endpoint"): + result_data["controller_endpoint"] = controller_config["endpoint"] + if controller_config.get("routerEndpoint"): + result_data["router_endpoint"] = controller_config["routerEndpoint"] + + except (json.JSONDecodeError, RuntimeError): + # Failed to get Helm values, but we still have basic info + pass + + break + + # Check for Jumpstarter CRDs as secondary verification + try: + crd_cmd = [kubectl, "--context", context, "get", "crd", "-o", "json"] + returncode, stdout, _ = await run_command(crd_cmd) + + if returncode == 0: + # Extract JSON from CRD output (handle warnings) + json_start = stdout.find("{") + if json_start >= 0: + json_output = stdout[json_start:] + crds = json.loads(json_output) + else: + crds = json.loads(stdout) # Fallback + jumpstarter_crds = [] + for item in crds.get("items", []): + name = item.get("metadata", {}).get("name", "") + if "jumpstarter.dev" in name: + jumpstarter_crds.append(name) + + if jumpstarter_crds: + result_data["has_crds"] = True + if not result_data["installed"]: + # CRDs exist but no Helm release found - manual installation? + result_data["installed"] = True + result_data["version"] = "unknown" + result_data["namespace"] = namespace or "unknown" + result_data["status"] = "manual-install" + except RuntimeError: + pass # CRD check failed, continue with Helm results + + except json.JSONDecodeError as e: + result_data["error"] = f"Failed to parse Helm output: {e}" + except RuntimeError as e: + result_data["error"] = f"Command failed: {e}" + + return V1Alpha1JumpstarterInstance(**result_data) + + +async def get_cluster_info( + context: str, + kubectl: str = "kubectl", + helm: str = "helm", + minikube: str = "minikube", +) -> V1Alpha1ClusterInfo: + """Get comprehensive cluster information.""" + try: + contexts = await get_kubectl_contexts(kubectl) + context_info = None + + for ctx in contexts: + if ctx["name"] == context: + context_info = ctx + break + + if not context_info: + return V1Alpha1ClusterInfo( + name=context, + cluster="unknown", + server="unknown", + user="unknown", + namespace="unknown", + is_current=False, + type="remote", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + error=f"Context '{context}' not found", + ) + + # Detect cluster type + from .detection import detect_cluster_type + + cluster_type = await detect_cluster_type(context_info["name"], context_info["server"], minikube) + + # Check if cluster is accessible + try: + version_cmd = [kubectl, "--context", context, "version", "-o", "json"] + returncode, stdout, _ = await run_command(version_cmd) + cluster_accessible = returncode == 0 + cluster_version = None + + if cluster_accessible: + try: + version_info = json.loads(stdout) + cluster_version = version_info.get("serverVersion", {}).get("gitVersion", "unknown") + except (json.JSONDecodeError, KeyError): + cluster_version = "unknown" + except RuntimeError: + cluster_accessible = False + cluster_version = None + + # Check Jumpstarter installation + if cluster_accessible: + jumpstarter_info = await check_jumpstarter_installation(context, None, helm, kubectl) + else: + jumpstarter_info = V1Alpha1JumpstarterInstance(installed=False, error="Cluster not accessible") + + return V1Alpha1ClusterInfo( + name=context_info["name"], + cluster=context_info["cluster"], + server=context_info["server"], + user=context_info["user"], + namespace=context_info["namespace"], + is_current=context_info["current"], + type=cluster_type, + accessible=cluster_accessible, + version=cluster_version, + jumpstarter=jumpstarter_info, + ) + + except Exception as e: + return V1Alpha1ClusterInfo( + name=context, + cluster="unknown", + server="unknown", + user="unknown", + namespace="unknown", + is_current=False, + type="remote", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + error=f"Failed to get cluster info: {e}", + ) + + +async def list_clusters( + cluster_type_filter: str = "all", + kubectl: str = "kubectl", + helm: str = "helm", + kind: str = "kind", + minikube: str = "minikube", +) -> V1Alpha1ClusterList: + """List all Kubernetes clusters with Jumpstarter status.""" + try: + contexts = await get_kubectl_contexts(kubectl) + cluster_infos = [] + + for context in contexts: + cluster_info = await get_cluster_info(context["name"], kubectl, helm, minikube) + + # Filter by type if specified + if cluster_type_filter != "all" and cluster_info.type != cluster_type_filter: + continue + + cluster_infos.append(cluster_info) + + return V1Alpha1ClusterList(items=cluster_infos) + + except Exception as e: + # Return empty list with error in the first cluster + error_cluster = V1Alpha1ClusterInfo( + name="error", + cluster="error", + server="error", + user="error", + namespace="error", + is_current=False, + type="remote", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + error=f"Failed to list clusters: {e}", + ) + return V1Alpha1ClusterList(items=[error_cluster]) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py new file mode 100644 index 000000000..284bce382 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -0,0 +1,392 @@ +"""Tests for kubectl operations and cluster management.""" + +import json +from unittest.mock import patch + +import pytest + +from jumpstarter_kubernetes.cluster.kubectl import ( + check_jumpstarter_installation, + check_kubernetes_access, + get_cluster_info, + get_kubectl_contexts, + list_clusters, +) +from jumpstarter_kubernetes.clusters import V1Alpha1ClusterInfo, V1Alpha1JumpstarterInstance +from jumpstarter_kubernetes.exceptions import JumpstarterKubernetesError + + +class TestCheckKubernetesAccess: + """Test Kubernetes cluster access checking.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_kubernetes_access_success(self, mock_run_command): + mock_run_command.return_value = (0, "cluster info", "") + + result = await check_kubernetes_access() + + assert result is True + mock_run_command.assert_called_once_with(["kubectl", "cluster-info", "--request-timeout=5s"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_kubernetes_access_with_context(self, mock_run_command): + mock_run_command.return_value = (0, "cluster info", "") + + result = await check_kubernetes_access(context="test-context") + + assert result is True + mock_run_command.assert_called_once_with( + ["kubectl", "--context", "test-context", "cluster-info", "--request-timeout=5s"] + ) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_kubernetes_access_custom_kubectl(self, mock_run_command): + mock_run_command.return_value = (0, "cluster info", "") + + result = await check_kubernetes_access(kubectl="custom-kubectl") + + assert result is True + mock_run_command.assert_called_once_with(["custom-kubectl", "cluster-info", "--request-timeout=5s"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_kubernetes_access_failure(self, mock_run_command): + mock_run_command.return_value = (1, "", "connection refused") + + result = await check_kubernetes_access() + + assert result is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_kubernetes_access_runtime_error(self, mock_run_command): + mock_run_command.side_effect = RuntimeError("Command failed") + + result = await check_kubernetes_access() + + assert result is False + + +class TestGetKubectlContexts: + """Test kubectl context retrieval.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_success(self, mock_run_command): + kubectl_config = { + "current-context": "test-context", + "contexts": [ + {"name": "test-context", "context": {"cluster": "test-cluster", "user": "test-user"}}, + {"name": "prod-context", "context": {"cluster": "prod-cluster", "user": "prod-user"}}, + ], + "clusters": [ + {"name": "test-cluster", "cluster": {"server": "https://test.example.com:6443"}}, + {"name": "prod-cluster", "cluster": {"server": "https://prod.example.com:6443"}}, + ], + } + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + result = await get_kubectl_contexts() + + assert len(result) == 2 + assert result[0] == { + "name": "test-context", + "cluster": "test-cluster", + "server": "https://test.example.com:6443", + "user": "test-user", + "namespace": "default", + "current": True, + } + assert result[1] == { + "name": "prod-context", + "cluster": "prod-cluster", + "server": "https://prod.example.com:6443", + "user": "prod-user", + "namespace": "default", + "current": False, + } + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_with_namespace(self, mock_run_command): + kubectl_config = { + "current-context": "test-context", + "contexts": [ + { + "name": "test-context", + "context": {"cluster": "test-cluster", "user": "test-user", "namespace": "custom-ns"}, + } + ], + "clusters": [{"name": "test-cluster", "cluster": {"server": "https://test.example.com:6443"}}], + } + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + result = await get_kubectl_contexts() + + assert len(result) == 1 + assert result[0]["namespace"] == "custom-ns" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_no_current_context(self, mock_run_command): + kubectl_config = { + "contexts": [{"name": "test-context", "context": {"cluster": "test-cluster"}}], + "clusters": [{"name": "test-cluster", "cluster": {"server": "https://test.example.com:6443"}}], + } + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + result = await get_kubectl_contexts() + + assert len(result) == 1 + assert result[0]["current"] is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_missing_cluster(self, mock_run_command): + kubectl_config = { + "current-context": "test-context", + "contexts": [{"name": "test-context", "context": {"cluster": "missing-cluster"}}], + "clusters": [], + } + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + result = await get_kubectl_contexts() + + assert len(result) == 1 + assert result[0]["server"] == "" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_command_failure(self, mock_run_command): + from jumpstarter_kubernetes.exceptions import KubeconfigError + + mock_run_command.return_value = (1, "", "permission denied") + + with pytest.raises(KubeconfigError, match="Failed to get kubectl config: permission denied"): + await get_kubectl_contexts() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_invalid_json(self, mock_run_command): + from jumpstarter_kubernetes.exceptions import KubeconfigError + + mock_run_command.return_value = (0, "invalid json", "") + + with pytest.raises(KubeconfigError, match="Failed to parse kubectl config"): + await get_kubectl_contexts() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): + kubectl_config = {"contexts": [], "clusters": []} + mock_run_command.return_value = (0, json.dumps(kubectl_config), "") + + await get_kubectl_contexts(kubectl="custom-kubectl") + + mock_run_command.assert_called_once_with(["custom-kubectl", "config", "view", "-o", "json"]) + + +class TestCheckJumpstarterInstallation: + """Test Jumpstarter installation checking.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_helm_found(self, mock_run_command): + helm_releases = [ + { + "chart": "jumpstarter-1.0.0", + "app_version": "1.0.0", + "namespace": "jumpstarter-system", + "name": "jumpstarter-release", + "status": "deployed", + } + ] + # Mock calls: helm list, kubectl get namespaces, kubectl get crds + mock_run_command.side_effect = [ + (0, json.dumps(helm_releases), ""), # helm list success + (0, '{"items": []}', ""), # kubectl get namespaces + (0, '{"items": []}', ""), # kubectl get crds + ] + + result = await check_jumpstarter_installation("test-context") + + assert result.installed is True + assert result.version == "1.0.0" + assert result.namespace == "jumpstarter-system" + assert result.chart_name == "jumpstarter-release" + assert result.status == "deployed" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_no_helm(self, mock_run_command): + # Helm command fails, fallback to kubectl + mock_run_command.side_effect = [ + (1, "", "helm not found"), # helm list fails + (0, '{"items": []}', ""), # kubectl get namespaces + (1, "", "not found"), # kubectl get crds + ] + + result = await check_jumpstarter_installation("test-context") + + assert result.installed is False + assert result.has_crds is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_namespace_found(self, mock_run_command): + crds_response = {"items": [{"metadata": {"name": "exporter.jumpstarter.dev"}}]} + + mock_run_command.side_effect = [ + (1, "", "helm not found"), # helm list fails + (0, json.dumps(crds_response), ""), # kubectl get crds + ] + + result = await check_jumpstarter_installation("test-context") + + assert result.installed is True + assert result.namespace == "unknown" + assert result.has_crds is True + assert result.status == "manual-install" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + async def test_check_jumpstarter_installation_custom_namespace(self, mock_run_command): + mock_run_command.side_effect = [ + (0, "[]", ""), # helm list + (0, '{"items": []}', ""), # kubectl get crds + ] + + await check_jumpstarter_installation("test-context", namespace="custom-ns") + + # Verify the helm command was called (namespace parameter not used in current implementation) + helm_call = mock_run_command.call_args_list[0] + assert "helm" in helm_call[0][0] + assert "list" in helm_call[0][0] + + +class TestGetClusterInfo: + """Test cluster info retrieval.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") + @patch("jumpstarter_kubernetes.cluster.kubectl.check_jumpstarter_installation") + async def test_get_cluster_info_success(self, mock_check_jumpstarter, mock_run_command, mock_get_contexts): + # Mock the context lookup + mock_get_contexts.return_value = [ + { + "name": "test-context", + "cluster": "test-cluster", + "server": "https://test.example.com", + "user": "test-user", + "namespace": "default", + "current": False, + } + ] + + version_output = {"serverVersion": {"gitVersion": "v1.28.0"}} + mock_run_command.return_value = (0, json.dumps(version_output), "") + mock_check_jumpstarter.return_value = V1Alpha1JumpstarterInstance(installed=True, version="1.0.0") + + result = await get_cluster_info("test-context") + + assert isinstance(result, V1Alpha1ClusterInfo) + assert result.name == "test-context" + assert result.cluster == "test-cluster" + assert result.server == "https://test.example.com" + assert result.accessible is True + assert result.version == "v1.28.0" + assert result.jumpstarter.installed is True + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_get_cluster_info_inaccessible(self, mock_get_contexts): + # Mock get_kubectl_contexts to fail + mock_get_contexts.side_effect = JumpstarterKubernetesError("Failed to get kubectl config: connection refused") + + result = await get_cluster_info("test-context") + + assert result.accessible is False + assert "Failed to get cluster info:" in result.error + assert "Failed to get kubectl config: connection refused" in result.error + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_get_cluster_info_invalid_json(self, mock_get_contexts): + # Mock get_kubectl_contexts to fail with JSON parse error + error_msg = "Failed to parse kubectl config: Expecting value: line 1 column 1 (char 0)" + mock_get_contexts.side_effect = JumpstarterKubernetesError(error_msg) + + result = await get_cluster_info("test-context") + + assert result.accessible is False # Function failed + assert "Failed to get cluster info" in result.error + assert "Failed to parse kubectl config" in result.error + + +class TestListClusters: + """Test cluster listing functionality.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + @patch("jumpstarter_kubernetes.cluster.kubectl.get_cluster_info") + async def test_list_clusters_success(self, mock_get_cluster_info, mock_get_contexts): + contexts = [ + { + "name": "test-context", + "cluster": "test-cluster", + "server": "https://test.example.com", + "user": "test-user", + "current": True, + } + ] + mock_get_contexts.return_value = contexts + + cluster_info = V1Alpha1ClusterInfo( + name="test-context", + cluster="test-cluster", + server="https://test.example.com", + user="test-user", + namespace="default", + is_current=True, + type="kind", + accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ) + mock_get_cluster_info.return_value = cluster_info + + result = await list_clusters() + + assert len(result.items) == 1 + assert result.items[0].name == "test-context" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_list_clusters_no_contexts(self, mock_get_contexts): + mock_get_contexts.return_value = [] + + result = await list_clusters() + + assert len(result.items) == 0 + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_list_clusters_context_error(self, mock_get_contexts): + mock_get_contexts.side_effect = JumpstarterKubernetesError("No kubeconfig found") + + result = await list_clusters() + + assert len(result.items) == 1 + assert result.items[0].error == "Failed to list clusters: No kubeconfig found" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.kubectl.get_kubectl_contexts") + async def test_list_clusters_custom_parameters(self, mock_get_contexts): + mock_get_contexts.return_value = [] + + await list_clusters(kubectl="custom-kubectl", helm="custom-helm", minikube="custom-minikube") + + mock_get_contexts.assert_called_once_with("custom-kubectl") diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py new file mode 100644 index 000000000..670cdaa3f --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py @@ -0,0 +1,263 @@ +"""Minikube cluster management operations.""" + +import json +import os +import shlex +import shutil +from pathlib import Path +from typing import List, Optional + +from ..callbacks import OutputCallback, SilentCallback +from ..exceptions import ( + CertificateError, + ClusterAlreadyExistsError, + ClusterOperationError, + ToolNotInstalledError, +) +from .common import run_command, run_command_with_output +from jumpstarter.common.ipaddr import get_minikube_ip + + +def minikube_installed(minikube: str) -> bool: + """Check if Minikube is installed and available in the PATH.""" + return shutil.which(minikube) is not None + + +async def minikube_cluster_exists(minikube: str, cluster_name: str) -> bool: # noqa: C901 + """Check if a Minikube cluster exists. + + Uses 'minikube profile list' to distinguish between stopped and non-existent clusters. + A stopped cluster still exists and will be listed in the profile list. + """ + if not minikube_installed(minikube): + return False + + try: + # Use profile list to check if cluster exists (works for both running and stopped clusters) + returncode, stdout, stderr = await run_command([minikube, "profile", "list", "-o", "json"]) + + if returncode == 0: + # Parse JSON output to find the profile + try: + profiles = json.loads(stdout) + # The output structure is {"valid": [...], "invalid": [...]} + if isinstance(profiles, dict): + valid_profiles = profiles.get("valid", []) + if isinstance(valid_profiles, list): + for profile in valid_profiles: + if isinstance(profile, dict) and profile.get("Name") == cluster_name: + return True + except (json.JSONDecodeError, KeyError, TypeError): + pass + + # Fallback: check status output for "profile not found" message + returncode, stdout, stderr = await run_command([minikube, "status", "-p", cluster_name]) + + # If status succeeds, cluster exists (running) + if returncode == 0: + return True + + # Check if the error indicates profile doesn't exist + combined_output = (stdout + stderr).lower() + if "profile" in combined_output and "not found" in combined_output: + return False + + # Non-zero exit but not "not found" means cluster exists but may be stopped + return True + + except RuntimeError as e: + # Check if the error message indicates profile not found + error_msg = str(e).lower() + if "profile" in error_msg and "not found" in error_msg: + return False + # Other errors may indicate the cluster exists but has issues + return True + + +async def delete_minikube_cluster(minikube: str, cluster_name: str, callback: OutputCallback = None) -> bool: + """Delete a Minikube cluster.""" + if callback is None: + callback = SilentCallback() + + if not minikube_installed(minikube): + raise ToolNotInstalledError("minikube") + + if not await minikube_cluster_exists(minikube, cluster_name): + return True # Already deleted, consider it successful + + callback.progress(f'Deleting Minikube cluster "{cluster_name}"...') + returncode = await run_command_with_output([minikube, "delete", "-p", cluster_name]) + + if returncode == 0: + callback.success(f'Successfully deleted Minikube cluster "{cluster_name}"') + return True + else: + raise ClusterOperationError( + "delete", cluster_name, "minikube", RuntimeError(f"Failed to delete Minikube cluster '{cluster_name}'") + ) + + +async def create_minikube_cluster( # noqa: C901 + minikube: str, + cluster_name: str, + extra_args: Optional[List[str]] = None, + force_recreate: bool = False, + callback: OutputCallback = None, +) -> bool: + """Create a Minikube cluster.""" + if extra_args is None: + extra_args = [] + if callback is None: + callback = SilentCallback() + + if not minikube_installed(minikube): + raise ToolNotInstalledError("minikube") + + # Check if cluster already exists + cluster_exists = await minikube_cluster_exists(minikube, cluster_name) + + if cluster_exists: + if not force_recreate: + callback.progress(f'Minikube cluster "{cluster_name}" already exists, continuing...') + return True + else: + if not await delete_minikube_cluster(minikube, cluster_name, callback): + return False + + has_cpus_flag = any(a == "--cpus" or a.startswith("--cpus=") for a in extra_args) + if not has_cpus_flag: + try: + rc, out, _ = await run_command([minikube, "config", "get", "cpus"]) + has_config_cpus = rc == 0 and out.strip().isdigit() and int(out.strip()) > 0 + except RuntimeError: + # If we cannot query minikube (e.g., not installed in test env), default CPUs + has_config_cpus = False + if not has_config_cpus: + extra_args.append("--cpus=4") + + command = [ + minikube, + "start", + "--profile", + cluster_name, + "--extra-config=apiserver.service-node-port-range=30000-32767", + ] + command.extend(extra_args) + + returncode = await run_command_with_output(command) + + if returncode == 0: + action_past = "recreated" if force_recreate else "created" + callback.success(f'Successfully {action_past} Minikube cluster "{cluster_name}"') + return True + else: + action = "recreate" if force_recreate else "create" + raise ClusterOperationError( + action, cluster_name, "minikube", RuntimeError(f"Failed to {action} Minikube cluster '{cluster_name}'") + ) + + +async def list_minikube_clusters(minikube: str) -> List[str]: + """List all Minikube clusters.""" + if not minikube_installed(minikube): + return [] + + try: + returncode, stdout, _ = await run_command([minikube, "profile", "list", "-o", "json"]) + if returncode == 0: + data = json.loads(stdout) + valid_profiles = data.get("valid", []) + return [profile["Name"] for profile in valid_profiles] + return [] + except (RuntimeError, json.JSONDecodeError, KeyError): + return [] + + +async def get_minikube_cluster_ip(minikube: str, cluster_name: str) -> str: + """Get the IP address of a Minikube cluster.""" + return await get_minikube_ip(cluster_name, minikube) + + +async def prepare_certificates(extra_certs: str, callback: OutputCallback = None) -> None: + """Prepare custom certificates for Minikube.""" + if callback is None: + callback = SilentCallback() + + # Expand ~ and environment variables before making absolute + expanded_path = os.path.expanduser(os.path.expandvars(extra_certs)) + extra_certs_path = os.path.abspath(expanded_path) + + if not os.path.exists(extra_certs_path): + raise CertificateError(f"Extra certificates file not found: {extra_certs_path}", extra_certs_path) + + # Create .minikube/certs directory if it doesn't exist + minikube_certs_dir = Path.home() / ".minikube" / "certs" + minikube_certs_dir.mkdir(parents=True, exist_ok=True) + + # Copy the certificate file to minikube certs directory + cert_dest = minikube_certs_dir / "ca.crt" + + # If ca.crt already exists, append to it + if cert_dest.exists(): + with open(extra_certs_path, "r") as src, open(cert_dest, "a") as dst: + dst.write("\n") + dst.write(src.read()) + else: + shutil.copy2(extra_certs_path, cert_dest) + + callback.success(f"Prepared custom certificates for Minikube: {cert_dest}") + + +async def create_minikube_cluster_with_options( + minikube: str, + cluster_name: str, + minikube_extra_args: str, + force_recreate_cluster: bool, + extra_certs: Optional[str] = None, + callback: OutputCallback = None, +) -> None: + """Create a Minikube cluster with optional certificate preparation.""" + if callback is None: + callback = SilentCallback() + + if not minikube_installed(minikube): + raise ToolNotInstalledError("minikube") + + cluster_action = "Recreating" if force_recreate_cluster else "Creating" + callback.progress(f'{cluster_action} Minikube cluster "{cluster_name}"...') + extra_args_list = shlex.split(minikube_extra_args) if minikube_extra_args.strip() else [] + + # Prepare custom certificates for Minikube if provided + if extra_certs: + await prepare_certificates(extra_certs, callback) + # Always add --embed-certs for container drivers + if "--embed-certs" not in extra_args_list: + extra_args_list.append("--embed-certs") + + try: + await create_minikube_cluster(minikube, cluster_name, extra_args_list, force_recreate_cluster, callback) + + except ClusterAlreadyExistsError as e: + if not force_recreate_cluster: + callback.progress(f'Minikube cluster "{cluster_name}" already exists, continuing...') + else: + raise ClusterOperationError("recreate", cluster_name, "minikube", e) from e + except Exception as e: + action = "recreate" if force_recreate_cluster else "create" + raise ClusterOperationError(action, cluster_name, "minikube", e) from e + + +async def delete_minikube_cluster_with_feedback( + minikube: str, cluster_name: str, callback: OutputCallback = None +) -> None: + """Delete a Minikube cluster with user feedback.""" + if callback is None: + callback = SilentCallback() + + if not minikube_installed(minikube): + raise ToolNotInstalledError("minikube") + + try: + await delete_minikube_cluster(minikube, cluster_name, callback) + except Exception as e: + raise ClusterOperationError("delete", cluster_name, "minikube", e) from e diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube_test.py new file mode 100644 index 000000000..ce624e242 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube_test.py @@ -0,0 +1,295 @@ +"""Tests for Minikube cluster management operations.""" + +from unittest.mock import patch + +import pytest + +from jumpstarter_kubernetes.cluster.minikube import ( + create_minikube_cluster, + delete_minikube_cluster, + minikube_cluster_exists, + minikube_installed, +) + + +class TestMinikubeInstalled: + """Test Minikube installation detection.""" + + @patch("jumpstarter_kubernetes.cluster.minikube.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.minikube.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") + + @patch("jumpstarter_kubernetes.cluster.minikube.shutil.which") + def test_minikube_installed_custom_binary(self, mock_which): + mock_which.return_value = "/custom/path/minikube" + assert minikube_installed("custom-minikube") is True + mock_which.assert_called_once_with("custom-minikube") + + +class TestMinikubeClusterExists: + """Test Minikube cluster existence checking.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command") + async def test_minikube_cluster_exists_true(self, mock_run_command, mock_minikube_installed): + mock_minikube_installed.return_value = True + # Mock profile list to return a valid profile + mock_run_command.return_value = ( + 0, + '{"valid": [{"Name": "test-cluster", "Status": "Running"}], "invalid": []}', + "" + ) + + result = await minikube_cluster_exists("minikube", "test-cluster") + + assert result is True + # Should call profile list first + mock_run_command.assert_called_with(["minikube", "profile", "list", "-o", "json"]) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command") + async def test_minikube_cluster_exists_false(self, mock_run_command, mock_minikube_installed): + mock_minikube_installed.return_value = True + mock_run_command.return_value = (1, "", "profile not found") + + result = await minikube_cluster_exists("minikube", "test-cluster") + + assert result is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.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 + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command") + async def test_minikube_cluster_exists_runtime_error(self, mock_run_command, mock_minikube_installed): + mock_minikube_installed.return_value = True + # RuntimeError with "profile not found" should return False + mock_run_command.side_effect = RuntimeError("profile not found") + + result = await minikube_cluster_exists("minikube", "test-cluster") + + assert result is False + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command") + async def test_minikube_cluster_exists_stopped_cluster(self, mock_run_command, mock_minikube_installed): + """Test that a stopped cluster is detected as existing""" + mock_minikube_installed.return_value = True + # Mock profile list to show stopped cluster + mock_run_command.return_value = ( + 0, + '{"valid": [{"Name": "test-cluster", "Status": "Stopped"}], "invalid": []}', + "" + ) + + result = await minikube_cluster_exists("minikube", "test-cluster") + + assert result is True + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command") + async def test_minikube_cluster_exists_custom_binary(self, mock_run_command, mock_minikube_installed): + mock_minikube_installed.return_value = True + # Mock profile list to return a valid profile + mock_run_command.return_value = ( + 0, + '{"valid": [{"Name": "test-cluster", "Status": "Running"}], "invalid": []}', + "" + ) + + result = await minikube_cluster_exists("custom-minikube", "test-cluster") + + assert result is True + # Should call profile list first + mock_run_command.assert_called_with(["custom-minikube", "profile", "list", "-o", "json"]) + + + + +class TestCreateMinikubeCluster: + """Test Minikube cluster creation.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.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=30000-32767" in args + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + async def test_create_minikube_cluster_not_installed(self, mock_minikube_installed): + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError + + mock_minikube_installed.return_value = False + + with pytest.raises(ToolNotInstalledError): + await create_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.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 + + # Should return True without raising error when cluster exists and force_recreate=False + result = await create_minikube_cluster("minikube", "test-cluster") + assert result is True + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.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.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command_with_output") + async def test_create_minikube_cluster_command_failure( + self, mock_run_command, mock_cluster_exists, mock_minikube_installed + ): + from jumpstarter_kubernetes.exceptions import ClusterOperationError + + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = False + mock_run_command.return_value = 1 + + with pytest.raises(ClusterOperationError): + await create_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command_with_output") + async def test_create_minikube_cluster_custom_binary( + 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("custom-minikube", "test-cluster") + + assert result is True + args = mock_run_command.call_args[0][0] + assert args[0] == "custom-minikube" + + +class TestDeleteMinikubeCluster: + """Test Minikube cluster deletion.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.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.minikube_installed") + async def test_delete_minikube_cluster_not_installed(self, mock_minikube_installed): + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError + + mock_minikube_installed.return_value = False + + with pytest.raises(ToolNotInstalledError): + await delete_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.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.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command_with_output") + async def test_delete_minikube_cluster_failure( + self, mock_run_command, mock_cluster_exists, mock_minikube_installed + ): + from jumpstarter_kubernetes.exceptions import ClusterOperationError + + mock_minikube_installed.return_value = True + mock_cluster_exists.return_value = True + mock_run_command.return_value = 1 + + with pytest.raises(ClusterOperationError): + await delete_minikube_cluster("minikube", "test-cluster") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_installed") + @patch("jumpstarter_kubernetes.cluster.minikube.minikube_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.minikube.run_command_with_output") + async def test_delete_minikube_cluster_custom_binary( + 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("custom-minikube", "test-cluster") + + assert result is True + mock_run_command.assert_called_once_with(["custom-minikube", "delete", "-p", "test-cluster"]) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py new file mode 100644 index 000000000..0f9a8f2c4 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py @@ -0,0 +1,221 @@ +"""High-level cluster operations and orchestration.""" + +from typing import Optional + +from ..callbacks import OutputCallback, SilentCallback +from ..exceptions import ( + ClusterNameValidationError, + ClusterNotFoundError, + ClusterOperationError, + ClusterTypeValidationError, + ToolNotInstalledError, +) +from ..install import helm_installed +from .common import ClusterType, validate_cluster_name +from .detection import auto_detect_cluster_type, detect_existing_cluster_type +from .endpoints import configure_endpoints +from .helm import install_jumpstarter_helm_chart +from .kind import ( + create_kind_cluster_with_options, + delete_kind_cluster_with_feedback, + kind_cluster_exists, + kind_installed, +) +from .minikube import ( + create_minikube_cluster_with_options, + delete_minikube_cluster_with_feedback, + minikube_cluster_exists, + minikube_installed, +) + + +def validate_cluster_type_selection(kind: Optional[str], minikube: Optional[str]) -> ClusterType: + """Validate cluster type selection and return the cluster type.""" + if kind and minikube: + raise ClusterTypeValidationError('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" + else: + # Auto-detect cluster type when neither is specified + return auto_detect_cluster_type() + + +async def delete_cluster_by_name( # noqa: C901 + cluster_name: str, cluster_type: Optional[str] = None, force: bool = False, callback: OutputCallback = None +) -> None: + """Delete a cluster by name, with auto-detection if type not specified.""" + if callback is None: + callback = SilentCallback() + + # Validate cluster name + try: + cluster_name = validate_cluster_name(cluster_name) + except Exception as e: + raise ClusterNameValidationError(cluster_name, str(e)) from e + + # If cluster type is specified, validate and use it + if cluster_type: + if cluster_type == "kind": + if not kind_installed("kind"): + raise ToolNotInstalledError("kind") + if not await kind_cluster_exists("kind", cluster_name): + raise ClusterNotFoundError(cluster_name, "kind") + elif cluster_type == "minikube": + if not minikube_installed("minikube"): + raise ToolNotInstalledError("minikube") + if not await minikube_cluster_exists("minikube", cluster_name): + raise ClusterNotFoundError(cluster_name, "minikube") + else: + # Unsupported cluster type specified + raise ClusterTypeValidationError(cluster_type, ["kind", "minikube"]) + else: + # Auto-detect cluster type + detected_type = await detect_existing_cluster_type(cluster_name) + if detected_type is None: + raise ClusterNotFoundError(cluster_name) + cluster_type = detected_type + callback.progress(f'Auto-detected {cluster_type} cluster "{cluster_name}"') + + # Validate cluster type is supported for deletion + if cluster_type not in ["kind", "minikube"]: + raise ClusterTypeValidationError(cluster_type, ["kind", "minikube"]) + + # Confirm deletion unless force is specified + if not force: + if not callback.confirm( + f'This will permanently delete the "{cluster_name}" {cluster_type} cluster and ALL its data. Continue?' + ): + callback.progress("Cluster deletion cancelled.") + return + + # Delete the cluster + if cluster_type == "kind": + await delete_kind_cluster_with_feedback("kind", cluster_name, callback) + elif cluster_type == "minikube": + await delete_minikube_cluster_with_feedback("minikube", cluster_name, callback) + + callback.success(f'Successfully deleted {cluster_type} cluster "{cluster_name}"') + + +async def create_cluster_and_install( + cluster_type: ClusterType, + force_recreate_cluster: bool, + cluster_name: str, + kind_extra_args: str, + minikube_extra_args: str, + kind: str, + minikube: str, + extra_certs: Optional[str] = None, + install_jumpstarter: bool = True, + helm: str = "helm", + chart: str = "oci://quay.io/jumpstarter-dev/helm/jumpstarter", + chart_name: str = "jumpstarter", + namespace: str = "jumpstarter-lab", + version: Optional[str] = None, + kubeconfig: Optional[str] = None, + context: Optional[str] = None, + ip: Optional[str] = None, + basedomain: Optional[str] = None, + grpc_endpoint: Optional[str] = None, + router_endpoint: Optional[str] = None, + callback: OutputCallback = None, + values_files: Optional[list[str]] = None, +) -> None: + """Create a cluster and optionally install Jumpstarter.""" + if callback is None: + callback = SilentCallback() + + # Validate cluster name + try: + cluster_name = validate_cluster_name(cluster_name) + except Exception as e: + raise ClusterNameValidationError(cluster_name, str(e)) from e + + if force_recreate_cluster: + callback.warning(f'⚠️ WARNING: Force recreating cluster "{cluster_name}" will destroy ALL data in the cluster!') + callback.warning("This includes:") + callback.warning(" • All deployed applications and services") + callback.warning(" • All persistent volumes and data") + callback.warning(" • All configurations and secrets") + callback.warning(" • All custom resources") + if not callback.confirm(f'Are you sure you want to recreate cluster "{cluster_name}"?'): + callback.progress("Cluster recreation cancelled.") + raise ClusterOperationError("recreate", cluster_name, cluster_type, Exception("User cancelled")) + + # Create the cluster + if cluster_type == "kind": + await create_kind_cluster_with_options( + kind, cluster_name, kind_extra_args, force_recreate_cluster, extra_certs, callback + ) + elif cluster_type == "minikube": + await create_minikube_cluster_with_options( + minikube, cluster_name, minikube_extra_args, force_recreate_cluster, extra_certs, callback + ) + else: + raise ClusterTypeValidationError(f"Unsupported cluster_type: {cluster_type}") + + # Install Jumpstarter if requested + if install_jumpstarter: + if not helm_installed(helm): + raise ToolNotInstalledError("helm", f"helm is not installed (or not in your PATH): {helm}") + + # Configure endpoints + actual_ip, actual_basedomain, actual_grpc, actual_router = await configure_endpoints( + cluster_type, minikube, cluster_name, ip, basedomain, grpc_endpoint, router_endpoint + ) + + # Version is required when installing Jumpstarter + if version is None: + raise ClusterOperationError( + "install", + cluster_name, + cluster_type, + Exception("Version must be specified when installing Jumpstarter"), + ) + + # Install Helm chart + await install_jumpstarter_helm_chart( + chart, + chart_name, + namespace, + actual_basedomain, + actual_grpc, + actual_router, + "nodeport", + version, + kubeconfig, + context, + helm, + actual_ip, + callback, + values_files, + ) + + +async def create_cluster_only( + cluster_type: ClusterType, + force_recreate_cluster: bool, + cluster_name: str, + kind_extra_args: str, + minikube_extra_args: str, + kind: str, + minikube: str, + custom_certs: Optional[str] = None, + callback: OutputCallback = None, +) -> None: + """Create a cluster without installing Jumpstarter.""" + await create_cluster_and_install( + cluster_type, + force_recreate_cluster, + cluster_name, + kind_extra_args, + minikube_extra_args, + kind, + minikube, + custom_certs, + install_jumpstarter=False, + callback=callback, + ) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py new file mode 100644 index 000000000..cd8e8f0a7 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations_test.py @@ -0,0 +1,180 @@ +"""Tests for high-level cluster operations.""" + +from unittest.mock import ANY, patch + +import pytest + +from jumpstarter_kubernetes.cluster.operations import ( + create_cluster_and_install, + create_cluster_only, + delete_cluster_by_name, +) +from jumpstarter_kubernetes.exceptions import ClusterNotFoundError, ClusterTypeValidationError + + +class TestDeleteClusterByName: + """Test cluster deletion by name.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.detect_existing_cluster_type") + @patch("jumpstarter_kubernetes.cluster.operations.delete_kind_cluster_with_feedback") + async def test_delete_cluster_by_name_kind(self, mock_delete_kind, mock_detect): + mock_detect.return_value = "kind" + mock_delete_kind.return_value = None + + await delete_cluster_by_name("test-cluster", force=True) + + mock_detect.assert_called_once_with("test-cluster") + mock_delete_kind.assert_called_once_with("kind", "test-cluster", ANY) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.detect_existing_cluster_type") + @patch("jumpstarter_kubernetes.cluster.operations.delete_minikube_cluster_with_feedback") + async def test_delete_cluster_by_name_minikube(self, mock_delete_minikube, mock_detect): + mock_detect.return_value = "minikube" + mock_delete_minikube.return_value = None + + await delete_cluster_by_name("test-cluster", force=True) + + mock_detect.assert_called_once_with("test-cluster") + mock_delete_minikube.assert_called_once_with("minikube", "test-cluster", ANY) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.detect_existing_cluster_type") + async def test_delete_cluster_by_name_not_found(self, mock_detect): + mock_detect.return_value = None + + with pytest.raises(ClusterNotFoundError): + await delete_cluster_by_name("test-cluster", force=True) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.detect_existing_cluster_type") + @patch("jumpstarter_kubernetes.cluster.operations.kind_installed") + @patch("jumpstarter_kubernetes.cluster.operations.kind_cluster_exists") + @patch("jumpstarter_kubernetes.cluster.operations.delete_kind_cluster_with_feedback") + async def test_delete_cluster_by_name_with_type( + self, mock_delete_kind, mock_cluster_exists, mock_installed, mock_detect + ): + mock_installed.return_value = True + mock_cluster_exists.return_value = True + mock_delete_kind.return_value = None + + await delete_cluster_by_name("test-cluster", cluster_type="kind", force=True) + + mock_detect.assert_not_called() + mock_installed.assert_called_once_with("kind") + mock_cluster_exists.assert_called_once_with("kind", "test-cluster") + mock_delete_kind.assert_called_once_with("kind", "test-cluster", ANY) + + @pytest.mark.asyncio + async def test_delete_cluster_unsupported_type_explicit(self): + """Test that explicitly specifying an unsupported cluster type raises ClusterTypeValidationError.""" + with pytest.raises(ClusterTypeValidationError) as exc_info: + await delete_cluster_by_name("test-cluster", cluster_type="remote", force=True) + + assert "remote" in str(exc_info.value) + assert "kind" in str(exc_info.value) + assert "minikube" in str(exc_info.value) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.detect_existing_cluster_type") + async def test_delete_cluster_unsupported_type_auto_detected(self, mock_detect): + """Test that auto-detecting an unsupported cluster type raises ClusterTypeValidationError.""" + mock_detect.return_value = "remote" + + with pytest.raises(ClusterTypeValidationError) as exc_info: + await delete_cluster_by_name("test-cluster", force=True) + + assert "remote" in str(exc_info.value) + assert "kind" in str(exc_info.value) + assert "minikube" in str(exc_info.value) + + +class TestCreateClusterOnly: + """Test cluster-only creation.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.create_cluster_and_install") + async def test_create_cluster_only_kind(self, mock_create_and_install): + mock_create_and_install.return_value = None + + await create_cluster_only("kind", False, "test-cluster", "", "", "kind", "minikube") + + mock_create_and_install.assert_called_once_with( + "kind", False, "test-cluster", "", "", "kind", "minikube", None, install_jumpstarter=False, callback=None + ) + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.create_cluster_and_install") + async def test_create_cluster_only_minikube(self, mock_create_and_install): + mock_create_and_install.return_value = None + + await create_cluster_only("minikube", False, "test-cluster", "", "", "kind", "minikube") + + mock_create_and_install.assert_called_once_with( + "minikube", + False, + "test-cluster", + "", + "", + "kind", + "minikube", + None, + install_jumpstarter=False, + callback=None, + ) + + +class TestCreateClusterAndInstall: + """Test cluster creation with installation.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.helm_installed") + @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") + @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") + @patch("jumpstarter_kubernetes.cluster.operations.install_jumpstarter_helm_chart") + async def test_create_cluster_and_install_success( + self, mock_install, mock_configure, mock_create, mock_helm_installed + ): + mock_helm_installed.return_value = True + mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") + + await create_cluster_and_install("kind", False, "test-cluster", "", "", "kind", "minikube", version="1.0.0") + + mock_create.assert_called_once() + mock_configure.assert_called_once() + mock_install.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.helm_installed") + @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") + async def test_create_cluster_and_install_no_helm(self, mock_create_wrapper, mock_helm_installed): + from jumpstarter_kubernetes.exceptions import ToolNotInstalledError + + mock_create_wrapper.return_value = None + mock_helm_installed.return_value = False + + with pytest.raises(ToolNotInstalledError): + await create_cluster_and_install("kind", False, "test-cluster", "", "", "kind", "minikube") + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.cluster.operations.helm_installed") + @patch("jumpstarter_kubernetes.cluster.operations.create_kind_cluster_with_options") + @patch("jumpstarter_kubernetes.cluster.operations.configure_endpoints") + async def test_create_cluster_and_install_no_version(self, mock_configure, mock_create, mock_helm_installed): + from jumpstarter_kubernetes.exceptions import ClusterOperationError + + mock_create.return_value = None + mock_helm_installed.return_value = True + mock_configure.return_value = ("192.168.1.100", "test.domain", "grpc.test:8082", "router.test:8083") + + with pytest.raises(ClusterOperationError): + await create_cluster_and_install("kind", False, "test-cluster", "", "", "kind", "minikube") + + @pytest.mark.asyncio + async def test_create_cluster_and_install_unsupported_cluster_type(self): + """Test that creating a cluster with an unsupported cluster type raises ClusterTypeValidationError.""" + with pytest.raises(ClusterTypeValidationError) as exc_info: + await create_cluster_and_install("remote", False, "test-cluster", "", "", "kind", "minikube") + + assert "Unsupported cluster_type: remote" in str(exc_info.value) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py deleted file mode 100644 index 1e66497c3..000000000 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster_test.py +++ /dev/null @@ -1,395 +0,0 @@ -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-kubernetes/jumpstarter_kubernetes/clusters.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py new file mode 100644 index 000000000..1031712af --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py @@ -0,0 +1,94 @@ +from typing import Literal, Optional + +from pydantic import Field + +from .json import JsonBaseModel +from .list import V1Alpha1List + + +class V1Alpha1JumpstarterInstance(JsonBaseModel): + """Information about Jumpstarter installation in a cluster""" + + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + kind: Literal["JumpstarterInstance"] = Field(default="JumpstarterInstance") + installed: bool + version: Optional[str] = None + namespace: Optional[str] = None + chart_name: Optional[str] = Field(alias="chartName", default=None) + status: Optional[str] = None + has_crds: bool = Field(alias="hasCrds", default=False) + error: Optional[str] = None + basedomain: Optional[str] = None + controller_endpoint: Optional[str] = Field(alias="controllerEndpoint", default=None) + router_endpoint: Optional[str] = Field(alias="routerEndpoint", default=None) + + +class V1Alpha1ClusterInfo(JsonBaseModel): + """Information about a Kubernetes cluster""" + + api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") + kind: Literal["ClusterInfo"] = Field(default="ClusterInfo") + name: str + cluster: str + server: str + user: str + namespace: str + is_current: bool = Field(alias="isCurrent") + type: Literal["kind", "minikube", "remote"] + accessible: bool + version: Optional[str] = None + jumpstarter: V1Alpha1JumpstarterInstance + error: Optional[str] = None + + @classmethod + def rich_add_columns(cls, table, **kwargs): + table.add_column("CURRENT") + table.add_column("NAME") + table.add_column("TYPE") + table.add_column("STATUS") + table.add_column("JUMPSTARTER") + table.add_column("VERSION") + table.add_column("NAMESPACE") + + + def rich_add_rows(self, table, **kwargs): + # Current indicator + current = "*" if self.is_current else "" + + # Status + status = "Running" if self.accessible else "Stopped" + + # Jumpstarter status + jumpstarter = "Yes" if self.jumpstarter.installed else "No" + if self.jumpstarter.error: + jumpstarter = "Error" + + # Version and namespace + version = self.jumpstarter.version or "-" + namespace = self.jumpstarter.namespace or "-" + + # Base row data + row_data = [current, self.name, self.type, status, jumpstarter, version, namespace] + + table.add_row(*row_data) + + def rich_add_names(self, names): + names.append(f"cluster/{self.name}") + + +class V1Alpha1ClusterList(V1Alpha1List[V1Alpha1ClusterInfo]): + """List of clusters""" + + kind: Literal["ClusterList"] = Field(default="ClusterList") + + @classmethod + def rich_add_columns(cls, table, **kwargs): + V1Alpha1ClusterInfo.rich_add_columns(table, **kwargs) + + def rich_add_rows(self, table, **kwargs): + for cluster in self.items: + cluster.rich_add_rows(table, **kwargs) + + def rich_add_names(self, names): + for cluster in self.items: + cluster.rich_add_names(names) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters_test.py new file mode 100644 index 000000000..5c6e7d899 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters_test.py @@ -0,0 +1,188 @@ +from unittest.mock import MagicMock + +from .clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance + + +def test_cluster_info_rich_add_columns(): + """Test that ClusterInfo can add columns to a rich table""" + mock_table = MagicMock() + V1Alpha1ClusterInfo.rich_add_columns(mock_table) + assert mock_table.add_column.call_count == 7 + mock_table.add_column.assert_any_call("CURRENT") + mock_table.add_column.assert_any_call("NAME") + mock_table.add_column.assert_any_call("TYPE") + mock_table.add_column.assert_any_call("STATUS") + mock_table.add_column.assert_any_call("JUMPSTARTER") + mock_table.add_column.assert_any_call("VERSION") + mock_table.add_column.assert_any_call("NAMESPACE") + + +def test_cluster_info_rich_add_rows_current_running_installed(): + """Test rich table row for current, running cluster with Jumpstarter installed""" + mock_table = MagicMock() + cluster_info = V1Alpha1ClusterInfo( + name="test-cluster", + cluster="test-cluster", + server="https://127.0.0.1:6443", + user="test-user", + namespace="default", + is_current=True, + type="kind", + accessible=True, + version="1.28.0", + jumpstarter=V1Alpha1JumpstarterInstance( + installed=True, + version="0.1.0", + namespace="jumpstarter", + ), + ) + cluster_info.rich_add_rows(mock_table) + mock_table.add_row.assert_called_once() + args = mock_table.add_row.call_args[0] + assert args[0] == "*" # current + assert args[1] == "test-cluster" # name + assert args[2] == "kind" # type + assert args[3] == "Running" # status + assert args[4] == "Yes" # jumpstarter + assert args[5] == "0.1.0" # version + assert args[6] == "jumpstarter" # namespace + + +def test_cluster_info_rich_add_rows_not_current_stopped_not_installed(): + """Test rich table row for non-current, stopped cluster without Jumpstarter""" + mock_table = MagicMock() + cluster_info = V1Alpha1ClusterInfo( + name="test-cluster", + cluster="test-cluster", + server="https://127.0.0.1:6443", + user="test-user", + namespace="default", + is_current=False, + type="minikube", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ) + cluster_info.rich_add_rows(mock_table) + mock_table.add_row.assert_called_once() + args = mock_table.add_row.call_args[0] + assert args[0] == "" # not current + assert args[1] == "test-cluster" # name + assert args[2] == "minikube" # type + assert args[3] == "Stopped" # status + assert args[4] == "No" # jumpstarter + assert args[5] == "-" # no version + assert args[6] == "-" # no namespace + + +def test_cluster_info_rich_add_rows_with_jumpstarter_error(): + """Test rich table row for cluster with Jumpstarter installation error""" + mock_table = MagicMock() + cluster_info = V1Alpha1ClusterInfo( + name="test-cluster", + cluster="test-cluster", + server="https://127.0.0.1:6443", + user="test-user", + namespace="default", + is_current=True, + type="kind", + accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance( + installed=False, + error="Failed to connect", + ), + ) + cluster_info.rich_add_rows(mock_table) + mock_table.add_row.assert_called_once() + args = mock_table.add_row.call_args[0] + assert args[4] == "Error" # jumpstarter status shows error + + +def test_cluster_info_rich_add_names(): + """Test that ClusterInfo can add names for name output""" + cluster_info = V1Alpha1ClusterInfo( + name="test-cluster", + cluster="test-cluster", + server="https://127.0.0.1:6443", + user="test-user", + namespace="default", + is_current=True, + type="kind", + accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ) + names = [] + cluster_info.rich_add_names(names) + assert names == ["cluster/test-cluster"] + + +def test_cluster_list_rich_add_columns(): + """Test that ClusterList can add columns to a rich table""" + mock_table = MagicMock() + V1Alpha1ClusterList.rich_add_columns(mock_table) + assert mock_table.add_column.call_count == 7 + + +def test_cluster_list_rich_add_rows(): + """Test that ClusterList can add rows for multiple clusters""" + mock_table = MagicMock() + cluster_list = V1Alpha1ClusterList( + items=[ + V1Alpha1ClusterInfo( + name="cluster1", + cluster="cluster1", + server="https://127.0.0.1:6443", + user="user1", + namespace="default", + is_current=True, + type="kind", + accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance(installed=True), + ), + V1Alpha1ClusterInfo( + name="cluster2", + cluster="cluster2", + server="https://192.168.49.2:8443", + user="user2", + namespace="default", + is_current=False, + type="minikube", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ), + ] + ) + cluster_list.rich_add_rows(mock_table) + assert mock_table.add_row.call_count == 2 + + +def test_cluster_list_rich_add_names(): + """Test that ClusterList can add names for all clusters""" + cluster_list = V1Alpha1ClusterList( + items=[ + V1Alpha1ClusterInfo( + name="cluster1", + cluster="cluster1", + server="https://127.0.0.1:6443", + user="user1", + namespace="default", + is_current=True, + type="kind", + accessible=True, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ), + V1Alpha1ClusterInfo( + name="cluster2", + cluster="cluster2", + server="https://192.168.49.2:8443", + user="user2", + namespace="default", + is_current=False, + type="minikube", + accessible=False, + jumpstarter=V1Alpha1JumpstarterInstance(installed=False), + ), + ] + ) + names = [] + cluster_list.rich_add_names(names) + assert names == ["cluster/cluster1", "cluster/cluster2"] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py new file mode 100644 index 000000000..6d532468c --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py @@ -0,0 +1,75 @@ +from typing import Optional + +import aiohttp +import semver +from packaging.version import Version + +from .exceptions import JumpstarterKubernetesError + + +async def get_latest_compatible_controller_version(client_version: Optional[str]): # noqa: C901 + """Get the latest compatible controller version for a given client version""" + if client_version is None: + # Return the latest available version when no client version is specified + use_fallback_only = True + client_version_parsed = None + else: + use_fallback_only = False + # Strip leading "v" for parsing but keep original for error messages + version_to_parse = client_version[1:] if client_version.startswith("v") else client_version + try: + client_version_parsed = Version(version_to_parse) + except Exception as e: + raise JumpstarterKubernetesError( + f"Invalid client version '{client_version}': {e}" + ) from e + + async with aiohttp.ClientSession( + raise_for_status=True, + ) as session: + try: + async with session.get( + "https://quay.io/api/v1/repository/jumpstarter-dev/helm/jumpstarter/tag/", + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + resp = await resp.json() + except Exception as e: + raise JumpstarterKubernetesError(f"Failed to fetch controller versions: {e}") from e + + compatible = set() + fallback = set() + + if not isinstance(resp, dict) or "tags" not in resp or not isinstance(resp["tags"], list): + raise JumpstarterKubernetesError("Unexpected response fetching controller version") + + for tag in resp["tags"]: + if not isinstance(tag, dict) or "name" not in tag: + continue # Skip malformed tag entries + + tag_name = tag["name"] + # Strip leading "v" for parsing but keep original tag name + version_str = tag_name[1:] if tag_name.startswith("v") else tag_name + + try: + version = semver.VersionInfo.parse(version_str) + except ValueError: + continue # ignore invalid versions + + if use_fallback_only: + # When no client version specified, all versions are candidates + fallback.add((version, tag_name)) + elif version.major == client_version_parsed.major and version.minor == client_version_parsed.minor: + compatible.add((version, tag_name)) + else: + fallback.add((version, tag_name)) + + if compatible: + # max() on tuples compares by first element (version), then second (tag_name) + selected_version, selected_tag = max(compatible) + elif fallback: + selected_version, selected_tag = max(fallback) + else: + raise JumpstarterKubernetesError("No valid controller versions found in the repository") + + # Return the original tag string (not str(Version) or VersionInfo) + return selected_tag diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py new file mode 100644 index 000000000..727fdc4de --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py @@ -0,0 +1,147 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from .datetime import time_since + + +def test_time_since_seconds(): + """Test time_since for elapsed time < 1 minute""" + now = datetime.now(timezone.utc) + past = now - timedelta(seconds=30) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "30s" + + +def test_time_since_minutes_with_seconds(): + """Test time_since for elapsed time in minutes with seconds""" + now = datetime.now(timezone.utc) + past = now - timedelta(minutes=5, seconds=30) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "5m30s" + + +def test_time_since_minutes_without_seconds(): + """Test time_since for elapsed time in exact minutes""" + now = datetime.now(timezone.utc) + past = now - timedelta(minutes=10) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "10m" + + +def test_time_since_hours_with_minutes_under_2h(): + """Test time_since for elapsed time in hours with minutes (under 2 hours)""" + now = datetime.now(timezone.utc) + past = now - timedelta(hours=1, minutes=30) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "1h30m" + + +def test_time_since_hours_without_minutes(): + """Test time_since for elapsed time in hours >= 2""" + now = datetime.now(timezone.utc) + past = now - timedelta(hours=3, minutes=15) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "3h" + + +def test_time_since_days_with_hours(): + """Test time_since for elapsed time in days with hours""" + now = datetime.now(timezone.utc) + past = now - timedelta(days=5, hours=6) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "5d6h" + + +def test_time_since_days_without_hours(): + """Test time_since for elapsed time in exact days""" + now = datetime.now(timezone.utc) + past = now - timedelta(days=10) + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "10d" + + +def test_time_since_months_with_days(): + """Test time_since for elapsed time in months with days""" + now = datetime.now(timezone.utc) + past = now - timedelta(days=65) # ~2 months + 5 days + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "2mo5d" + + +def test_time_since_months_without_days(): + """Test time_since for elapsed time in exact months""" + now = datetime.now(timezone.utc) + past = now - timedelta(days=90) # Exactly 3 months + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "3mo" + + +def test_time_since_years_with_months(): + """Test time_since for elapsed time in years with months""" + now = datetime.now(timezone.utc) + past = now - timedelta(days=425) # ~1 year + 2 months + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "1y2mo" + + +def test_time_since_years_without_months(): + """Test time_since for elapsed time in exact years""" + now = datetime.now(timezone.utc) + past = now - timedelta(days=730) # Exactly 2 years + t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") + + with patch("jumpstarter_kubernetes.datetime.datetime") as mock_datetime: + mock_datetime.now.return_value = now + mock_datetime.strptime.return_value = past.replace(tzinfo=None) + result = time_since(t_str) + assert result == "2y" diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py new file mode 100644 index 000000000..ce56cd2c7 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py @@ -0,0 +1,102 @@ +"""Custom exceptions for jumpstarter-kubernetes package. + +This module defines domain-specific exceptions to replace click.ClickException +and provide better error handling without CLI framework dependencies. +""" + + +class JumpstarterKubernetesError(Exception): + """Base exception for all jumpstarter-kubernetes errors.""" + + pass + + +class ToolNotInstalledError(JumpstarterKubernetesError): + """Raised when a required tool (kind, minikube, helm, kubectl) is not installed.""" + + def __init__(self, tool_name: str, additional_info: str = ""): + self.tool_name = tool_name + message = f"{tool_name} is not installed (or not in your PATH)" + if additional_info: + message += f": {additional_info}" + super().__init__(message) + + +class ClusterNotFoundError(JumpstarterKubernetesError): + """Raised when a cluster cannot be found.""" + + def __init__(self, cluster_name: str, cluster_type: str = None): + self.cluster_name = cluster_name + self.cluster_type = cluster_type + if cluster_type: + message = f'{cluster_type.title()} cluster "{cluster_name}" does not exist' + else: + message = f'No cluster named "{cluster_name}" found' + super().__init__(message) + + +class ClusterAlreadyExistsError(JumpstarterKubernetesError): + """Raised when trying to create a cluster that already exists.""" + + def __init__(self, cluster_name: str, cluster_type: str): + self.cluster_name = cluster_name + self.cluster_type = cluster_type + message = f'{cluster_type.title()} cluster "{cluster_name}" already exists' + super().__init__(message) + + +class ClusterOperationError(JumpstarterKubernetesError): + """Raised when a cluster operation (create, delete, etc.) fails.""" + + def __init__(self, operation: str, cluster_name: str, cluster_type: str, cause: Exception = None): + self.operation = operation + self.cluster_name = cluster_name + self.cluster_type = cluster_type + self.cause = cause + if cause: + message = f"Failed to {operation} {cluster_type} cluster: {cause}" + else: + message = f"Failed to {operation} {cluster_type} cluster" + super().__init__(message) + + +class CertificateError(JumpstarterKubernetesError): + """Raised when certificate operations fail.""" + + def __init__(self, message: str, certificate_path: str = None): + self.certificate_path = certificate_path + super().__init__(message) + + +class KubeconfigError(JumpstarterKubernetesError): + """Raised when kubectl configuration operations fail.""" + + def __init__(self, message: str, config_path: str = None): + self.config_path = config_path + super().__init__(message) + + +class ClusterTypeValidationError(JumpstarterKubernetesError): + """Raised when cluster type validation fails.""" + + def __init__(self, cluster_type: str, supported_types: list = None): + self.cluster_type = cluster_type + self.supported_types = supported_types or ["kind", "minikube"] + message = f'Unsupported cluster type "{cluster_type}". Supported types: {", ".join(self.supported_types)}' + super().__init__(message) + + +class ClusterNameValidationError(JumpstarterKubernetesError): + """Raised when cluster name validation fails.""" + + def __init__(self, cluster_name: str, reason: str = "Cluster name cannot be empty"): + self.cluster_name = cluster_name + super().__init__(reason) + + +class EndpointConfigurationError(JumpstarterKubernetesError): + """Raised when endpoint configuration fails.""" + + def __init__(self, message: str, cluster_type: str = None): + self.cluster_type = cluster_type + super().__init__(message) diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index 78104c0ad..1792a0f3b 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -75,3 +75,188 @@ def test_exporter_dump_yaml(): endpoint: https://test-exporter """ ) + + +def test_exporter_from_dict(): + """Test V1Alpha1Exporter.from_dict""" + from jumpstarter_kubernetes import V1Alpha1Exporter + + test_dict = { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "test-exporter", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": { + "credential": {"name": "test-credential"}, + "devices": [{"labels": {"test": "label"}, "uuid": "f4cf49ab-fc64-46c6-94e7-a40502eb77b1"}], + "endpoint": "https://test-exporter", + }, + } + exporter = V1Alpha1Exporter.from_dict(test_dict) + assert exporter.metadata.name == "test-exporter" + assert exporter.status.endpoint == "https://test-exporter" + assert len(exporter.status.devices) == 1 + assert exporter.status.devices[0].uuid == "f4cf49ab-fc64-46c6-94e7-a40502eb77b1" + + +def test_exporter_rich_add_columns_without_devices(): + """Test V1Alpha1Exporter.rich_add_columns without devices""" + from unittest.mock import MagicMock + + from jumpstarter_kubernetes import V1Alpha1Exporter + + mock_table = MagicMock() + V1Alpha1Exporter.rich_add_columns(mock_table, devices=False) + assert mock_table.add_column.call_count == 4 + mock_table.add_column.assert_any_call("NAME", no_wrap=True) + mock_table.add_column.assert_any_call("ENDPOINT") + mock_table.add_column.assert_any_call("DEVICES") + mock_table.add_column.assert_any_call("AGE") + + +def test_exporter_rich_add_columns_with_devices(): + """Test V1Alpha1Exporter.rich_add_columns with devices""" + from unittest.mock import MagicMock + + from jumpstarter_kubernetes import V1Alpha1Exporter + + mock_table = MagicMock() + V1Alpha1Exporter.rich_add_columns(mock_table, devices=True) + assert mock_table.add_column.call_count == 5 + mock_table.add_column.assert_any_call("NAME", no_wrap=True) + mock_table.add_column.assert_any_call("ENDPOINT") + mock_table.add_column.assert_any_call("AGE") + mock_table.add_column.assert_any_call("LABELS") + mock_table.add_column.assert_any_call("UUID") + + +def test_exporter_rich_add_rows_without_devices(): + """Test V1Alpha1Exporter.rich_add_rows without devices flag""" + from unittest.mock import MagicMock, patch + + mock_table = MagicMock() + with patch("jumpstarter_kubernetes.exporters.time_since", return_value="5m"): + TEST_EXPORTER.rich_add_rows(mock_table, devices=False) + mock_table.add_row.assert_called_once() + args = mock_table.add_row.call_args[0] + assert args[0] == "test-exporter" + assert args[1] == "https://test-exporter" + assert args[2] == "1" # Number of devices + assert args[3] == "5m" # Age + + +def test_exporter_rich_add_rows_with_devices(): + """Test V1Alpha1Exporter.rich_add_rows with devices flag""" + from unittest.mock import MagicMock, patch + + mock_table = MagicMock() + with patch("jumpstarter_kubernetes.exporters.time_since", return_value="5m"): + TEST_EXPORTER.rich_add_rows(mock_table, devices=True) + mock_table.add_row.assert_called_once() + args = mock_table.add_row.call_args[0] + assert args[0] == "test-exporter" + assert args[1] == "https://test-exporter" + assert args[2] == "5m" # Age + assert args[3] == "test:label" # Labels + assert args[4] == "f4cf49ab-fc64-46c6-94e7-a40502eb77b1" # UUID + + +def test_exporter_rich_add_names(): + """Test V1Alpha1Exporter.rich_add_names""" + names = [] + TEST_EXPORTER.rich_add_names(names) + assert names == ["exporter.jumpstarter.dev/test-exporter"] + + +def test_exporter_list_from_dict(): + """Test V1Alpha1ExporterList.from_dict""" + from jumpstarter_kubernetes import V1Alpha1ExporterList + + test_dict = { + "items": [ + { + "apiVersion": "jumpstarter.dev/v1alpha1", + "kind": "Exporter", + "metadata": { + "creationTimestamp": "2021-10-01T00:00:00Z", + "generation": 1, + "name": "exporter1", + "namespace": "default", + "resourceVersion": "1", + "uid": "7a25eb81-6443-47ec-a62f-50165bffede8", + }, + "status": { + "credential": {"name": "cred1"}, + "devices": [], + "endpoint": "https://exporter1", + }, + } + ] + } + exporter_list = V1Alpha1ExporterList.from_dict(test_dict) + assert len(exporter_list.items) == 1 + assert exporter_list.items[0].metadata.name == "exporter1" + + +def test_exporter_list_rich_add_columns(): + """Test V1Alpha1ExporterList.rich_add_columns""" + from unittest.mock import MagicMock + + from jumpstarter_kubernetes import V1Alpha1ExporterList + + mock_table = MagicMock() + V1Alpha1ExporterList.rich_add_columns(mock_table, devices=False) + assert mock_table.add_column.call_count == 4 + + +def test_exporter_list_rich_add_columns_with_devices(): + """Test V1Alpha1ExporterList.rich_add_columns with devices""" + from unittest.mock import MagicMock + + from jumpstarter_kubernetes import V1Alpha1ExporterList + + mock_table = MagicMock() + V1Alpha1ExporterList.rich_add_columns(mock_table, devices=True) + assert mock_table.add_column.call_count == 5 + + +def test_exporter_list_rich_add_rows(): + """Test V1Alpha1ExporterList.rich_add_rows""" + from unittest.mock import MagicMock, patch + + from jumpstarter_kubernetes import V1Alpha1ExporterList + + exporter_list = V1Alpha1ExporterList(items=[TEST_EXPORTER]) + mock_table = MagicMock() + with patch("jumpstarter_kubernetes.exporters.time_since", return_value="5m"): + exporter_list.rich_add_rows(mock_table, devices=False) + assert mock_table.add_row.call_count == 1 + + +def test_exporter_list_rich_add_rows_with_devices(): + """Test V1Alpha1ExporterList.rich_add_rows with devices""" + from unittest.mock import MagicMock, patch + + from jumpstarter_kubernetes import V1Alpha1ExporterList + + exporter_list = V1Alpha1ExporterList(items=[TEST_EXPORTER]) + mock_table = MagicMock() + with patch("jumpstarter_kubernetes.exporters.time_since", return_value="5m"): + exporter_list.rich_add_rows(mock_table, devices=True) + assert mock_table.add_row.call_count == 1 + + +def test_exporter_list_rich_add_names(): + """Test V1Alpha1ExporterList.rich_add_names""" + from jumpstarter_kubernetes import V1Alpha1ExporterList + + exporter_list = V1Alpha1ExporterList(items=[TEST_EXPORTER]) + names = [] + exporter_list.rich_add_names(names) + assert names == ["exporter.jumpstarter.dev/test-exporter"] diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py index c99d473fb..34514f9e5 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install.py @@ -20,6 +20,7 @@ async def install_helm_chart( kubeconfig: Optional[str], context: Optional[str], helm: Optional[str] = "helm", + values_files: Optional[list[str]] = None, ): args = [ helm, @@ -55,6 +56,11 @@ async def install_helm_chart( args.append("--kube-context") args.append(context) + if values_files is not None: + for values_file in values_files: + args.append("-f") + args.append(values_file) + # Attempt to install Jumpstarter using Helm process = await asyncio.create_subprocess_exec(*args) await process.wait() diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py new file mode 100644 index 000000000..cf0668960 --- /dev/null +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/install_test.py @@ -0,0 +1,258 @@ +"""Tests for Helm installation functions.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from jumpstarter_kubernetes.install import helm_installed, install_helm_chart + + +class TestHelmInstalled: + """Test helm_installed function.""" + + @patch("jumpstarter_kubernetes.install.shutil.which") + def test_helm_installed_true(self, mock_which): + mock_which.return_value = "/usr/local/bin/helm" + assert helm_installed("helm") is True + mock_which.assert_called_once_with("helm") + + @patch("jumpstarter_kubernetes.install.shutil.which") + def test_helm_installed_false(self, mock_which): + mock_which.return_value = None + assert helm_installed("helm") is False + mock_which.assert_called_once_with("helm") + + @patch("jumpstarter_kubernetes.install.shutil.which") + def test_helm_installed_custom_path(self, mock_which): + mock_which.return_value = "/custom/path/helm" + assert helm_installed("/custom/path/helm") is True + mock_which.assert_called_once_with("/custom/path/helm") + + +class TestInstallHelmChart: + """Test install_helm_chart function.""" + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") + async def test_install_helm_chart_basic(self, mock_subprocess): + """Test basic helm chart installation without values files.""" + mock_process = AsyncMock() + mock_process.wait = AsyncMock(return_value=0) + mock_subprocess.return_value = mock_process + + await install_helm_chart( + chart="oci://quay.io/jumpstarter/helm", + name="jumpstarter", + namespace="jumpstarter-lab", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="nodeport", + version="1.0.0", + kubeconfig=None, + context=None, + helm="helm", + values_files=None, + ) + + # Verify the subprocess was called with correct arguments + args = mock_subprocess.call_args[0] + assert args[0] == "helm" + assert args[1] == "upgrade" + assert args[2] == "jumpstarter" + assert "--install" in args + assert "oci://quay.io/jumpstarter/helm" in args + assert "--namespace" in args + assert "jumpstarter-lab" in args + assert "--version" in args + assert "1.0.0" in args + assert "--wait" in args + + # Verify no -f flags when values_files is None + assert "-f" not in args + + mock_process.wait.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") + async def test_install_helm_chart_with_single_values_file(self, mock_subprocess): + """Test helm chart installation with a single values file.""" + mock_process = AsyncMock() + mock_process.wait = AsyncMock(return_value=0) + mock_subprocess.return_value = mock_process + + values_files = ["/path/to/values.yaml"] + + await install_helm_chart( + chart="oci://quay.io/jumpstarter/helm", + name="jumpstarter", + namespace="jumpstarter-lab", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="nodeport", + version="1.0.0", + kubeconfig=None, + context=None, + helm="helm", + values_files=values_files, + ) + + # Verify the subprocess was called with correct arguments including -f + args = mock_subprocess.call_args[0] + assert "-f" in args + f_index = args.index("-f") + assert args[f_index + 1] == "/path/to/values.yaml" + + mock_process.wait.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") + async def test_install_helm_chart_with_multiple_values_files(self, mock_subprocess): + """Test helm chart installation with multiple values files.""" + mock_process = AsyncMock() + mock_process.wait = AsyncMock(return_value=0) + mock_subprocess.return_value = mock_process + + values_files = ["/path/to/values.yaml", "/path/to/values.kind.yaml"] + + await install_helm_chart( + chart="oci://quay.io/jumpstarter/helm", + name="jumpstarter", + namespace="jumpstarter-lab", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="nodeport", + version="1.0.0", + kubeconfig=None, + context=None, + helm="helm", + values_files=values_files, + ) + + # Verify the subprocess was called with correct arguments including multiple -f flags + args = mock_subprocess.call_args[0] + + # Find all -f flags + f_indices = [i for i, arg in enumerate(args) if arg == "-f"] + assert len(f_indices) == 2 + + # Verify the values files are in the correct order + assert args[f_indices[0] + 1] == "/path/to/values.yaml" + assert args[f_indices[1] + 1] == "/path/to/values.kind.yaml" + + mock_process.wait.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") + async def test_install_helm_chart_with_kubeconfig_and_context(self, mock_subprocess): + """Test helm chart installation with kubeconfig and context.""" + mock_process = AsyncMock() + mock_process.wait = AsyncMock(return_value=0) + mock_subprocess.return_value = mock_process + + await install_helm_chart( + chart="oci://quay.io/jumpstarter/helm", + name="jumpstarter", + namespace="jumpstarter-lab", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="nodeport", + version="1.0.0", + kubeconfig="/path/to/kubeconfig", + context="test-context", + helm="helm", + values_files=None, + ) + + # Verify the subprocess was called with kubeconfig and context + args = mock_subprocess.call_args[0] + assert "--kubeconfig" in args + kubeconfig_index = args.index("--kubeconfig") + assert args[kubeconfig_index + 1] == "/path/to/kubeconfig" + + assert "--kube-context" in args + context_index = args.index("--kube-context") + assert args[context_index + 1] == "test-context" + + mock_process.wait.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") + async def test_install_helm_chart_with_all_options(self, mock_subprocess): + """Test helm chart installation with all options including values files, kubeconfig, and context.""" + mock_process = AsyncMock() + mock_process.wait = AsyncMock(return_value=0) + mock_subprocess.return_value = mock_process + + values_files = ["/path/to/values1.yaml", "/path/to/values2.yaml", "/path/to/values3.yaml"] + + await install_helm_chart( + chart="oci://quay.io/jumpstarter/helm", + name="jumpstarter", + namespace="jumpstarter-lab", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="ingress", + version="1.0.0", + kubeconfig="/path/to/kubeconfig", + context="prod-context", + helm="/usr/local/bin/helm", + values_files=values_files, + ) + + # Verify all options are present + args = mock_subprocess.call_args[0] + + # Check helm binary + assert args[0] == "/usr/local/bin/helm" + + # Check kubeconfig and context + assert "--kubeconfig" in args + assert "/path/to/kubeconfig" in args + assert "--kube-context" in args + assert "prod-context" in args + + # Check values files + f_indices = [i for i, arg in enumerate(args) if arg == "-f"] + assert len(f_indices) == 3 + assert args[f_indices[0] + 1] == "/path/to/values1.yaml" + assert args[f_indices[1] + 1] == "/path/to/values2.yaml" + assert args[f_indices[2] + 1] == "/path/to/values3.yaml" + + # Check mode + assert "jumpstarter-controller.grpc.mode=ingress" in args + + mock_process.wait.assert_called_once() + + @pytest.mark.asyncio + @patch("jumpstarter_kubernetes.install.asyncio.create_subprocess_exec") + async def test_install_helm_chart_empty_values_files_list(self, mock_subprocess): + """Test helm chart installation with empty values files list.""" + mock_process = AsyncMock() + mock_process.wait = AsyncMock(return_value=0) + mock_subprocess.return_value = mock_process + + await install_helm_chart( + chart="oci://quay.io/jumpstarter/helm", + name="jumpstarter", + namespace="jumpstarter-lab", + basedomain="jumpstarter.192.168.1.100.nip.io", + grpc_endpoint="grpc.jumpstarter.192.168.1.100.nip.io:8082", + router_endpoint="router.jumpstarter.192.168.1.100.nip.io:8083", + mode="nodeport", + version="1.0.0", + kubeconfig=None, + context=None, + helm="helm", + values_files=[], + ) + + # Verify no -f flags when values_files is empty list + args = mock_subprocess.call_args[0] + assert "-f" not in args + + mock_process.wait.assert_called_once() diff --git a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py index ef523d38c..d7a45cb5c 100644 --- a/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py +++ b/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/test_leases.py @@ -33,7 +33,6 @@ def test_lease_dump_json(): - print(TEST_LEASE.dump_json()) assert ( TEST_LEASE.dump_json() == """{ @@ -81,7 +80,6 @@ def test_lease_dump_json(): def test_lease_dump_yaml(): - print(TEST_LEASE.dump_yaml()) assert ( TEST_LEASE.dump_yaml() == """apiVersion: jumpstarter.dev/v1alpha1 diff --git a/packages/jumpstarter-kubernetes/pyproject.toml b/packages/jumpstarter-kubernetes/pyproject.toml index 1f8faa17e..39335b9c0 100644 --- a/packages/jumpstarter-kubernetes/pyproject.toml +++ b/packages/jumpstarter-kubernetes/pyproject.toml @@ -11,6 +11,9 @@ dependencies = [ "pydantic>=2.8.2", "kubernetes>=31.0.0", "kubernetes-asyncio>=31.1.0", + "aiohttp>=3.11.18", + "semver~=2.13", + "packaging>=25.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 34e74332e..3433ae26d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [manifest] @@ -800,40 +800,53 @@ wheels = [ [[package]] name = "grpcio" -version = "1.73.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/7b/ca3f561aeecf0c846d15e1b38921a60dffffd5d4113931198fbf455334ee/grpcio-1.73.0.tar.gz", hash = "sha256:3af4c30918a7f0d39de500d11255f8d9da4f30e94a2033e70fe2a720e184bd8e", size = 12786424, upload-time = "2025-06-09T10:08:23.365Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/31/9de81fd12f7b27e6af403531b7249d76f743d58e0654e624b3df26a43ce2/grpcio-1.73.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:51036f641f171eebe5fa7aaca5abbd6150f0c338dab3a58f9111354240fe36ec", size = 5363773, upload-time = "2025-06-09T10:03:08.056Z" }, - { url = "https://files.pythonhosted.org/packages/32/9e/2cb78be357a7f1fc4942b81468ef3c7e5fd3df3ac010540459c10895a57b/grpcio-1.73.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d12bbb88381ea00bdd92c55aff3da3391fd85bc902c41275c8447b86f036ce0f", size = 10621912, upload-time = "2025-06-09T10:03:10.489Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/b43954811a2e218a2761c0813800773ac0ca187b94fd2b8494e8ef232dc8/grpcio-1.73.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:483c507c2328ed0e01bc1adb13d1eada05cc737ec301d8e5a8f4a90f387f1790", size = 5807985, upload-time = "2025-06-09T10:03:13.775Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bf/68e9f47e7ee349ffee712dcd907ee66826cf044f0dec7ab517421e56e857/grpcio-1.73.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c201a34aa960c962d0ce23fe5f423f97e9d4b518ad605eae6d0a82171809caaa", size = 6448218, upload-time = "2025-06-09T10:03:16.042Z" }, - { url = "https://files.pythonhosted.org/packages/af/dd/38ae43dd58480d609350cf1411fdac5c2ebb243e2c770f6f7aa3773d5e29/grpcio-1.73.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:859f70c8e435e8e1fa060e04297c6818ffc81ca9ebd4940e180490958229a45a", size = 6044343, upload-time = "2025-06-09T10:03:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/93/44/b6770b55071adb86481f36dae87d332fcad883b7f560bba9a940394ba018/grpcio-1.73.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e2459a27c6886e7e687e4e407778425f3c6a971fa17a16420227bda39574d64b", size = 6135858, upload-time = "2025-06-09T10:03:21.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9f/63de49fcef436932fcf0ffb978101a95c83c177058dbfb56dbf30ab81659/grpcio-1.73.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e0084d4559ee3dbdcce9395e1bc90fdd0262529b32c417a39ecbc18da8074ac7", size = 6775806, upload-time = "2025-06-09T10:03:23.876Z" }, - { url = "https://files.pythonhosted.org/packages/4d/67/c11f1953469162e958f09690ec3a9be3fdb29dea7f5661362a664f9d609a/grpcio-1.73.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef5fff73d5f724755693a464d444ee0a448c6cdfd3c1616a9223f736c622617d", size = 6308413, upload-time = "2025-06-09T10:03:26.033Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6a/9dd04426337db07f28bd51a986b7a038ba56912c81b5bb1083c17dd63404/grpcio-1.73.0-cp311-cp311-win32.whl", hash = "sha256:965a16b71a8eeef91fc4df1dc40dc39c344887249174053814f8a8e18449c4c3", size = 3678972, upload-time = "2025-06-09T10:03:28.433Z" }, - { url = "https://files.pythonhosted.org/packages/04/8b/8c0a8a4fdc2e7977d325eafc587c9cf468039693ac23ad707153231d3cb2/grpcio-1.73.0-cp311-cp311-win_amd64.whl", hash = "sha256:b71a7b4483d1f753bbc11089ff0f6fa63b49c97a9cc20552cded3fcad466d23b", size = 4342967, upload-time = "2025-06-09T10:03:31.215Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/e938f3a0e51a47f2ce7e55f12f19f316e7074770d56a7c2765e782ec76bc/grpcio-1.73.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fb9d7c27089d9ba3746f18d2109eb530ef2a37452d2ff50f5a6696cd39167d3b", size = 5334911, upload-time = "2025-06-09T10:03:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/13/56/f09c72c43aa8d6f15a71f2c63ebdfac9cf9314363dea2598dc501d8370db/grpcio-1.73.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:128ba2ebdac41e41554d492b82c34586a90ebd0766f8ebd72160c0e3a57b9155", size = 10601460, upload-time = "2025-06-09T10:03:36.613Z" }, - { url = "https://files.pythonhosted.org/packages/20/e3/85496edc81e41b3c44ebefffc7bce133bb531120066877df0f910eabfa19/grpcio-1.73.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:068ecc415f79408d57a7f146f54cdf9f0acb4b301a52a9e563973dc981e82f3d", size = 5759191, upload-time = "2025-06-09T10:03:39.838Z" }, - { url = "https://files.pythonhosted.org/packages/88/cc/fef74270a6d29f35ad744bfd8e6c05183f35074ff34c655a2c80f3b422b2/grpcio-1.73.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ddc1cfb2240f84d35d559ade18f69dcd4257dbaa5ba0de1a565d903aaab2968", size = 6409961, upload-time = "2025-06-09T10:03:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e6/13cfea15e3b8f79c4ae7b676cb21fab70978b0fde1e1d28bb0e073291290/grpcio-1.73.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53007f70d9783f53b41b4cf38ed39a8e348011437e4c287eee7dd1d39d54b2f", size = 6003948, upload-time = "2025-06-09T10:03:44.96Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ed/b1a36dad4cc0dbf1f83f6d7b58825fefd5cc9ff3a5036e46091335649473/grpcio-1.73.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4dd8d8d092efede7d6f48d695ba2592046acd04ccf421436dd7ed52677a9ad29", size = 6103788, upload-time = "2025-06-09T10:03:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c8/d381433d3d46d10f6858126d2d2245ef329e30f3752ce4514c93b95ca6fc/grpcio-1.73.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:70176093d0a95b44d24baa9c034bb67bfe2b6b5f7ebc2836f4093c97010e17fd", size = 6749508, upload-time = "2025-06-09T10:03:51.185Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/ff0c31dbd15e63b34320efafac647270aa88c31aa19ff01154a73dc7ce86/grpcio-1.73.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:085ebe876373ca095e24ced95c8f440495ed0b574c491f7f4f714ff794bbcd10", size = 6284342, upload-time = "2025-06-09T10:03:54.467Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/f762430c0ba867403b9d6e463afe026bf019bd9206eee753785239719273/grpcio-1.73.0-cp312-cp312-win32.whl", hash = "sha256:cfc556c1d6aef02c727ec7d0016827a73bfe67193e47c546f7cadd3ee6bf1a60", size = 3669319, upload-time = "2025-06-09T10:03:56.751Z" }, - { url = "https://files.pythonhosted.org/packages/10/8b/3411609376b2830449cf416f457ad9d2aacb7f562e1b90fdd8bdedf26d63/grpcio-1.73.0-cp312-cp312-win_amd64.whl", hash = "sha256:bbf45d59d090bf69f1e4e1594832aaf40aa84b31659af3c5e2c3f6a35202791a", size = 4335596, upload-time = "2025-06-09T10:03:59.866Z" }, - { url = "https://files.pythonhosted.org/packages/60/da/6f3f7a78e5455c4cbe87c85063cc6da05d65d25264f9d4aed800ece46294/grpcio-1.73.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:da1d677018ef423202aca6d73a8d3b2cb245699eb7f50eb5f74cae15a8e1f724", size = 5335867, upload-time = "2025-06-09T10:04:03.153Z" }, - { url = "https://files.pythonhosted.org/packages/53/14/7d1f2526b98b9658d7be0bb163fd78d681587de6709d8b0c74b4b481b013/grpcio-1.73.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:36bf93f6a657f37c131d9dd2c391b867abf1426a86727c3575393e9e11dadb0d", size = 10595587, upload-time = "2025-06-09T10:04:05.694Z" }, - { url = "https://files.pythonhosted.org/packages/02/24/a293c398ae44e741da1ed4b29638edbb002258797b07a783f65506165b4c/grpcio-1.73.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:d84000367508ade791d90c2bafbd905574b5ced8056397027a77a215d601ba15", size = 5765793, upload-time = "2025-06-09T10:04:09.235Z" }, - { url = "https://files.pythonhosted.org/packages/e1/24/d84dbd0b5bf36fb44922798d525a85cefa2ffee7b7110e61406e9750ed15/grpcio-1.73.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c98ba1d928a178ce33f3425ff823318040a2b7ef875d30a0073565e5ceb058d9", size = 6415494, upload-time = "2025-06-09T10:04:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/5e/85/c80dc65aed8e9dce3d54688864bac45331d9c7600985541f18bd5cb301d4/grpcio-1.73.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a73c72922dfd30b396a5f25bb3a4590195ee45ecde7ee068acb0892d2900cf07", size = 6007279, upload-time = "2025-06-09T10:04:14.878Z" }, - { url = "https://files.pythonhosted.org/packages/37/fc/207c00a4c6fa303d26e2cbd62fbdb0582facdfd08f55500fd83bf6b0f8db/grpcio-1.73.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:10e8edc035724aba0346a432060fd192b42bd03675d083c01553cab071a28da5", size = 6105505, upload-time = "2025-06-09T10:04:17.39Z" }, - { url = "https://files.pythonhosted.org/packages/72/35/8fe69af820667b87ebfcb24214e42a1d53da53cb39edd6b4f84f6b36da86/grpcio-1.73.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f5cdc332b503c33b1643b12ea933582c7b081957c8bc2ea4cc4bc58054a09288", size = 6753792, upload-time = "2025-06-09T10:04:19.989Z" }, - { url = "https://files.pythonhosted.org/packages/e2/d8/738c77c1e821e350da4a048849f695ff88a02b291f8c69db23908867aea6/grpcio-1.73.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:07ad7c57233c2109e4ac999cb9c2710c3b8e3f491a73b058b0ce431f31ed8145", size = 6287593, upload-time = "2025-06-09T10:04:22.878Z" }, - { url = "https://files.pythonhosted.org/packages/09/ec/8498eabc018fa39ae8efe5e47e3f4c1bc9ed6281056713871895dc998807/grpcio-1.73.0-cp313-cp313-win32.whl", hash = "sha256:0eb5df4f41ea10bda99a802b2a292d85be28958ede2a50f2beb8c7fc9a738419", size = 3668637, upload-time = "2025-06-09T10:04:25.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/35/347db7d2e7674b621afd21b12022e7f48c7b0861b5577134b4e939536141/grpcio-1.73.0-cp313-cp313-win_amd64.whl", hash = "sha256:38cf518cc54cd0c47c9539cefa8888549fcc067db0b0c66a46535ca8032020c4", size = 4335872, upload-time = "2025-06-09T10:04:29.032Z" }, +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/77/b2f06db9f240a5abeddd23a0e49eae2b6ac54d85f0e5267784ce02269c3b/grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31", size = 5487368, upload-time = "2025-07-24T18:53:03.548Z" }, + { url = "https://files.pythonhosted.org/packages/48/99/0ac8678a819c28d9a370a663007581744a9f2a844e32f0fa95e1ddda5b9e/grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4", size = 10999804, upload-time = "2025-07-24T18:53:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/a2d586300d9e14ad72e8dc211c7aecb45fe9846a51e558c5bca0c9102c7f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce", size = 5987667, upload-time = "2025-07-24T18:53:07.157Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/5f338bf56a7f22584e68d669632e521f0de460bb3749d54533fc3d0fca4f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3", size = 6655612, upload-time = "2025-07-24T18:53:09.244Z" }, + { url = "https://files.pythonhosted.org/packages/82/ea/a4820c4c44c8b35b1903a6c72a5bdccec92d0840cf5c858c498c66786ba5/grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182", size = 6219544, upload-time = "2025-07-24T18:53:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/0537630a921365928f5abb6d14c79ba4dcb3e662e0dbeede8af4138d9dcf/grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d", size = 6334863, upload-time = "2025-07-24T18:53:12.925Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a6/85ca6cb9af3f13e1320d0a806658dca432ff88149d5972df1f7b51e87127/grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f", size = 7019320, upload-time = "2025-07-24T18:53:15.002Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/fe2beab970a1e25d2eff108b3cf4f7d9a53c185106377a3d1989216eba45/grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4", size = 6514228, upload-time = "2025-07-24T18:53:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c2/2f9c945c8a248cebc3ccda1b7a1bf1775b9d7d59e444dbb18c0014e23da6/grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b", size = 3817216, upload-time = "2025-07-24T18:53:20.564Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d1/a9cf9c94b55becda2199299a12b9feef0c79946b0d9d34c989de6d12d05d/grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11", size = 4495380, upload-time = "2025-07-24T18:53:22.058Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5d/e504d5d5c4469823504f65687d6c8fb97b7f7bf0b34873b7598f1df24630/grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8", size = 5445551, upload-time = "2025-07-24T18:53:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/730e37056f96f2f6ce9f17999af1556df62ee8dab7fa48bceeaab5fd3008/grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6", size = 10979810, upload-time = "2025-07-24T18:53:25.349Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/09fd100473ea5c47083889ca47ffd356576173ec134312f6aa0e13111dee/grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5", size = 5941946, upload-time = "2025-07-24T18:53:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/8a/99/12d2cca0a63c874c6d3d195629dcd85cdf5d6f98a30d8db44271f8a97b93/grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49", size = 6621763, upload-time = "2025-07-24T18:53:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2c/930b0e7a2f1029bbc193443c7bc4dc2a46fedb0203c8793dcd97081f1520/grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7", size = 6180664, upload-time = "2025-07-24T18:53:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/ff8a2442180ad0867717e670f5ec42bfd8d38b92158ad6bcd864e6d4b1ed/grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3", size = 6301083, upload-time = "2025-07-24T18:53:32.454Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/b361d390451a37ca118e4ec7dccec690422e05bc85fba2ec72b06cefec9f/grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707", size = 6994132, upload-time = "2025-07-24T18:53:34.506Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0c/3a5fa47d2437a44ced74141795ac0251bbddeae74bf81df3447edd767d27/grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b", size = 6489616, upload-time = "2025-07-24T18:53:36.217Z" }, + { url = "https://files.pythonhosted.org/packages/ae/95/ab64703b436d99dc5217228babc76047d60e9ad14df129e307b5fec81fd0/grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c", size = 3807083, upload-time = "2025-07-24T18:53:37.911Z" }, + { url = "https://files.pythonhosted.org/packages/84/59/900aa2445891fc47a33f7d2f76e00ca5d6ae6584b20d19af9c06fa09bf9a/grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc", size = 4490123, upload-time = "2025-07-24T18:53:39.528Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d8/1004a5f468715221450e66b051c839c2ce9a985aa3ee427422061fcbb6aa/grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89", size = 5449488, upload-time = "2025-07-24T18:53:41.174Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/33731a03f63740d7743dced423846c831d8e6da808fcd02821a4416df7fa/grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01", size = 10974059, upload-time = "2025-07-24T18:53:43.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c6/3d2c14d87771a421205bdca991467cfe473ee4c6a1231c1ede5248c62ab8/grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e", size = 5945647, upload-time = "2025-07-24T18:53:45.269Z" }, + { url = "https://files.pythonhosted.org/packages/c5/83/5a354c8aaff58594eef7fffebae41a0f8995a6258bbc6809b800c33d4c13/grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91", size = 6626101, upload-time = "2025-07-24T18:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ca/4fdc7bf59bf6994aa45cbd4ef1055cd65e2884de6113dbd49f75498ddb08/grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249", size = 6182562, upload-time = "2025-07-24T18:53:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/fd/48/2869e5b2c1922583686f7ae674937986807c2f676d08be70d0a541316270/grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362", size = 6303425, upload-time = "2025-07-24T18:53:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/bac93147b9a164f759497bc6913e74af1cb632c733c7af62c0336782bd38/grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f", size = 6996533, upload-time = "2025-07-24T18:53:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/84/35/9f6b2503c1fd86d068b46818bbd7329db26a87cdd8c01e0d1a9abea1104c/grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20", size = 6491489, upload-time = "2025-07-24T18:53:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/a04e99be2a82c4cbc4039eb3a76f6c3632932b9d5d295221389d10ac9ca7/grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa", size = 3805811, upload-time = "2025-07-24T18:53:56.798Z" }, + { url = "https://files.pythonhosted.org/packages/34/80/de3eb55eb581815342d097214bed4c59e806b05f1b3110df03b2280d6dfd/grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24", size = 4489214, upload-time = "2025-07-24T18:53:59.771Z" }, +] + +[[package]] +name = "grpcio-reflection" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/13/68116ec2c127019e2f50a13b38ec7b26e3c7de523ed42c4088fdcd23aca3/grpcio_reflection-1.74.0.tar.gz", hash = "sha256:c7327d2520dcdac209872ebf57774c3239646dad882e4abb4ad7bebccaca2c83", size = 18811, upload-time = "2025-07-24T19:01:56.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/36/74841fd268a8f8b85eb6647f2d962461dc3b1f7fc7850c7b7e7a1f3effc0/grpcio_reflection-1.74.0-py3-none-any.whl", hash = "sha256:ad1c4e94185f6def18f298f40f719603118f59d646939bb827f7bc72400f9ba0", size = 22696, upload-time = "2025-07-24T19:01:47.793Z" }, ] [[package]] @@ -1158,11 +1171,9 @@ dev = [ name = "jumpstarter-cli-admin" source = { editable = "packages/jumpstarter-cli-admin" } dependencies = [ - { name = "aiohttp" }, + { name = "grpcio-reflection" }, { name = "jumpstarter-cli-common" }, { name = "jumpstarter-kubernetes" }, - { name = "packaging" }, - { name = "semver" }, ] [package.dev-dependencies] @@ -1175,11 +1186,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.11.18" }, + { name = "grpcio-reflection", specifier = ">=1.60.0" }, { name = "jumpstarter-cli-common", editable = "packages/jumpstarter-cli-common" }, { name = "jumpstarter-kubernetes", editable = "packages/jumpstarter-kubernetes" }, - { name = "packaging", specifier = ">=25.0" }, - { name = "semver", specifier = "~=2.13" }, ] [package.metadata.requires-dev] @@ -2209,10 +2218,13 @@ dev = [ name = "jumpstarter-kubernetes" source = { editable = "packages/jumpstarter-kubernetes" } dependencies = [ + { name = "aiohttp" }, { name = "jumpstarter" }, { name = "kubernetes" }, { name = "kubernetes-asyncio" }, + { name = "packaging" }, { name = "pydantic" }, + { name = "semver" }, ] [package.dev-dependencies] @@ -2225,10 +2237,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.11.18" }, { name = "jumpstarter", editable = "packages/jumpstarter" }, { name = "kubernetes", specifier = ">=31.0.0" }, { name = "kubernetes-asyncio", specifier = ">=31.1.0" }, + { name = "packaging", specifier = ">=25.0" }, { name = "pydantic", specifier = ">=2.8.2" }, + { name = "semver", specifier = "~=2.13" }, ] [package.metadata.requires-dev]