diff --git a/.github/workflows/build_and_test_snap.yaml b/.github/workflows/build_and_test_snap.yaml index 6eff6db..b2be7d2 100644 --- a/.github/workflows/build_and_test_snap.yaml +++ b/.github/workflows/build_and_test_snap.yaml @@ -9,7 +9,8 @@ on: env: SNAP_ARTIFACT_NAME: dss-snap SNAP_FILE: dss.snap - KUBECONFIG: ~/.dss/config + # Path to the home dir of the strictly confined data-science-stack snap + DSS_KUBECONFIG_PATH: ~/snap/data-science-stack/x1/.dss/config jobs: build-snap: @@ -50,7 +51,6 @@ jobs: run: | # use --dangerous so we trust the snap even though it doesn't have signatures sudo snap install ${{ env.SNAP_FILE }} --dangerous - sudo snap connect data-science-stack:dot-dss-config sudo snap alias data-science-stack.dss dss - name: Set up Microk8s @@ -64,9 +64,9 @@ jobs: - name: Export kubeconfig where strict snap can access it run: | - mkdir -p ~/.dss - cp ~/.kube/config ${{ env.KUBECONFIG }} + mkdir -p $(dirname ${{ env.DSS_KUBECONFIG_PATH }}) + cp ~/.kube/config ${{ env.DSS_KUBECONFIG_PATH }} - name: Test dss snap run: | - tox -e dss-cli -- --kubeconfig ${{ env.KUBECONFIG }} + tox -e dss-cli diff --git a/snapcraft.yaml b/snapcraft.yaml index 18416a5..946ad93 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -10,11 +10,6 @@ confinement: strict architectures: - build-on: amd64 -plugs: - dot-dss-config: - interface: personal-files - read: - - $HOME/.dss/config apps: dss: command: bin/dss diff --git a/src/dss/main.py b/src/dss/main.py index e0d28fe..7c33669 100644 --- a/src/dss/main.py +++ b/src/dss/main.py @@ -11,7 +11,7 @@ from dss.start import start_notebook from dss.status import get_status from dss.stop import stop_notebook -from dss.utils import KUBECONFIG_DEFAULT, get_default_kubeconfig, get_lightkube_client +from dss.utils import KUBECONFIG_DEFAULT, get_lightkube_client, save_kubeconfig # Set up logger logger = setup_logger("logs/dss.log") @@ -25,7 +25,7 @@ def main(): @main.command(name="initialize") @click.option( "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 + help=f"Content of a Kubernetes config file defining the cluster to use. The kubeconfig will be saved to {KUBECONFIG_DEFAULT} and overwrite any kubeconfig previously stored there. Future `dss` commands will reuse this kubeconfig by default.", # noqa E501 ) def initialize_command(kubeconfig: str) -> None: """ @@ -34,9 +34,15 @@ def initialize_command(kubeconfig: str) -> None: logger.info("Executing initialize command") try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + if kubeconfig: + save_kubeconfig(kubeconfig=kubeconfig) + except Exception as e: + logger.debug(f"Failed to save kubeconfig: {e}.", exc_info=True) + logger.error(f"Failed to save kubeconfig: {str(e)}.") + click.get_current_context().exit(1) + try: + lightkube_client = get_lightkube_client() initialize(lightkube_client=lightkube_client) except RuntimeError: click.get_current_context().exit(1) @@ -46,6 +52,16 @@ def initialize_command(kubeconfig: str) -> None: click.get_current_context().exit(1) +initialize_command.help += """ + +\b +Examples + # To initialize DSS with microk8s's kubeconfig + dss initialize --kubeconfig "$(microk8s config)" + +""" + + IMAGE_OPTION_HELP = "\b\nThe image used for the notebook server.\n" @@ -59,13 +75,7 @@ def initialize_command(kubeconfig: str) -> None: default=DEFAULT_NOTEBOOK_IMAGE, help=IMAGE_OPTION_HELP, ) -# FIXME: Remove the kubeconfig param from the create command (and any tests) after -# https://github.com/canonical/data-science-stack/issues/37 -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) -def create_notebook_command(name: str, image: str, kubeconfig: str) -> None: +def create_notebook_command(name: str, image: str) -> None: """Create a Jupyter notebook in DSS and connect it to MLflow. This command also outputs the URL to access the notebook on success. @@ -79,8 +89,7 @@ def create_notebook_command(name: str, image: str, kubeconfig: str) -> None: ) try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() create_notebook(name=name, image=image, lightkube_client=lightkube_client) except RuntimeError: @@ -101,16 +110,12 @@ def create_notebook_command(name: str, image: str, kubeconfig: str) -> None: @main.command(name="logs") -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) @click.argument("notebook_name", required=False) @click.option( "--all", "print_all", is_flag=True, help="Print the logs for all notebooks and MLflow." ) @click.option("--mlflow", is_flag=True, help="Print the logs for the MLflow deployment.") -def logs_command(kubeconfig: str, notebook_name: str, print_all: bool, mlflow: bool) -> None: +def logs_command(notebook_name: str, print_all: bool, mlflow: bool) -> None: """Prints the logs for the specified notebook or DSS component. \b @@ -126,8 +131,7 @@ def logs_command(kubeconfig: str, notebook_name: str, print_all: bool, mlflow: b return try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() if print_all: get_logs("all", None, lightkube_client) @@ -144,15 +148,10 @@ def logs_command(kubeconfig: str, notebook_name: str, print_all: bool, mlflow: b @main.command(name="status") -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) -def status_command(kubeconfig: str) -> None: +def status_command() -> None: """Checks the status of key components within the DSS environment. Verifies if the MLflow deployment is ready and checks if GPU acceleration is enabled on the Kubernetes cluster by examining the labels of Kubernetes nodes for NVIDIA or Intel GPU devices.""" # noqa E501 try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() get_status(lightkube_client) except RuntimeError: @@ -170,19 +169,14 @@ def status_command(kubeconfig: str) -> None: is_flag=True, help="Display full information without truncation.", ) -@click.option( - "--kubeconfig", - help="Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to './kubeconfig'.", # noqa E501 -) -def list_command(kubeconfig: str, wide: bool): +def list_command(wide: bool): """ Lists all created notebooks in the DSS environment. The output is truncated to 80 characters. Use the --wide flag to display full information. """ try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() list_notebooks(lightkube_client, wide) except RuntimeError: click.get_current_context().exit(1) @@ -193,12 +187,8 @@ def list_command(kubeconfig: str, wide: bool): @main.command(name="stop") -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) @click.argument("notebook_name", required=True) -def stop_notebook_command(kubeconfig: str, notebook_name: str): +def stop_notebook_command(notebook_name: str): """ Stops a running notebook in the DSS environment. \b @@ -206,8 +196,7 @@ def stop_notebook_command(kubeconfig: str, notebook_name: str): dss stop my-notebook """ try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() stop_notebook(name=notebook_name, lightkube_client=lightkube_client) except RuntimeError: click.get_current_context().exit(1) @@ -217,18 +206,12 @@ def stop_notebook_command(kubeconfig: str, notebook_name: str): click.get_current_context().exit(1) -# FIXME: remove the `--kubeconfig`` option -# after fixing https://github.com/canonical/data-science-stack/issues/37 @main.command(name="start") @click.argument( "name", required=True, ) -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) -def start_notebook_command(name: str, kubeconfig: str): +def start_notebook_command(name: str): """ Starts a stopped notebook in the DSS environment. \b @@ -238,8 +221,7 @@ def start_notebook_command(name: str, kubeconfig: str): logger.info("Executing start command") try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() start_notebook(name=name, lightkube_client=lightkube_client) except RuntimeError: click.get_current_context().exit(1) @@ -250,26 +232,19 @@ def start_notebook_command(name: str, kubeconfig: str): click.get_current_context().exit(1) -# FIXME: remove the `--kubeconfig`` option -# after fixing https://github.com/canonical/data-science-stack/issues/37 @main.command(name="remove") @click.argument( "name", required=True, ) -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) -def remove_notebook_command(name: str, kubeconfig: str): +def remove_notebook_command(name: str): """ Remove a Jupter Notebook in DSS with the name NAME. """ logger.info("Executing remove command") try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() remove_notebook(name=name, lightkube_client=lightkube_client) except RuntimeError: @@ -281,19 +256,12 @@ def remove_notebook_command(name: str, kubeconfig: str): @main.command(name="purge") -@click.option( - "--kubeconfig", - help=f"Path to a Kubernetes config file. Defaults to the value of the KUBECONFIG environment variable, else to '{KUBECONFIG_DEFAULT}'.", # noqa E501 -) -# FIXME: Remove the kubeconfig param from the create command (and any tests) after -# https://github.com/canonical/data-science-stack/issues/37 -def purge_command(kubeconfig: str) -> None: +def purge_command() -> None: """ Removes all notebooks and DSS components. """ try: - kubeconfig = get_default_kubeconfig(kubeconfig) - lightkube_client = get_lightkube_client(kubeconfig) + lightkube_client = get_lightkube_client() purge(lightkube_client=lightkube_client) except RuntimeError: click.get_current_context().exit(1) diff --git a/src/dss/utils.py b/src/dss/utils.py index d0fd33e..cc75687 100644 --- a/src/dss/utils.py +++ b/src/dss/utils.py @@ -1,7 +1,9 @@ import os import time -from typing import Optional +from pathlib import Path +from typing import Union +import lightkube from lightkube import ApiError, Client, KubeConfig from lightkube.resources.apps_v1 import Deployment from lightkube.resources.core_v1 import Namespace, Node, PersistentVolumeClaim, Pod, Service @@ -17,7 +19,7 @@ # Name for the environment variable storing kubeconfig KUBECONFIG_ENV_VAR = "DSS_KUBECONFIG" -KUBECONFIG_DEFAULT = "./kubeconfig" +KUBECONFIG_DEFAULT = Path.home() / ".dss/config" class ImagePullBackOffError(Exception): @@ -87,35 +89,89 @@ def wait_for_deployment_ready( ) -def get_default_kubeconfig(kubeconfig: Optional[str] = None) -> str: +def get_kubeconfig_path( + env_var: str = KUBECONFIG_ENV_VAR, + default_kubeconfig_location: Union[Path, str] = KUBECONFIG_DEFAULT, +) -> Path: """ - Get the kubeconfig file path, from input, environment variable, or default. + Returns the path to the kubeconfig used by DSS + + This will return: + * the kubeconfig file at the path specified by the given environment variable, if set + * otherwise, the kubeconfig file at the default path given Args: - kubeconfig (str): Path to a kubeconfig file. Defaults to None. + env_var (str): The name of the environment variable to check for the kubeconfig path. + default_kubeconfig_location (Path or str): The default path to the kubeconfig file if not + specified by the environment variable. Returns: - str: the value of kubeconfig if it is not None, else: - the value of the DSS_KUBECONFIG environment variable if it is set, else: - the default value of "./kubeconfig" + Path: the path to the kubeconfig file + """ + # use expanduser() to handle '~' in the path + return Path(os.environ.get(env_var, default_kubeconfig_location)).expanduser() + +def get_kubeconfig( + env_var: str = KUBECONFIG_ENV_VAR, + default_kubeconfig_location: Union[Path, str] = KUBECONFIG_DEFAULT, +) -> lightkube.KubeConfig: + """Returns the kubeconfig used by DSS as a lightkube.KubeConfig object or fails if it not found. + + This will return: + * the kubeconfig file at the path specified by the given environment variable, if set + * otherwise, the kubeconfig file at the default path given + + Args: + env_var (str): The name of the environment variable to check for the kubeconfig path. + default_kubeconfig_location (Path or str): The default path to the kubeconfig file if not + specified by the environment variable. + + Returns: + lightkube.KubeConfig: the value of kubeconfig """ - if kubeconfig: - return kubeconfig - elif os.environ.get(KUBECONFIG_ENV_VAR, ""): - return os.environ.get(KUBECONFIG_ENV_VAR, "") - else: - return KUBECONFIG_DEFAULT + kubeconfig_path = get_kubeconfig_path(env_var, default_kubeconfig_location) + return KubeConfig.from_file(kubeconfig_path) + + +def save_kubeconfig( + kubeconfig: str, + env_var: str = KUBECONFIG_ENV_VAR, + default_kubeconfig_location: Union[Path, str] = KUBECONFIG_DEFAULT, +) -> None: + """ + Save the kubeconfig file to the specified location. + + This will create the parent directory if it does not exist. + + Args: + kubeconfig (str): The kubeconfig file contents. + env_var (str): The name of the environment variable to check for the kubeconfig path. + default_kubeconfig_location (str): The default path to the kubeconfig file if not + specified by the environment variable. + + Returns: + None + """ + save_location = get_kubeconfig_path(env_var, default_kubeconfig_location) + logger.info(f"Storing provided kubeconfig to {save_location}") + + # Create the parent directory, if it does not exist + save_location.parent.mkdir(exist_ok=True) + + with open(save_location, "w") as f: + f.write(kubeconfig) -def get_lightkube_client(kubeconfig: Optional[str] = None): - # Compute the default kubeconfig, if not specified - kubeconfig = KubeConfig.from_file(kubeconfig) +def get_lightkube_client() -> lightkube.Client: + """Returns a lightkube client configured with the kubeconfig used by DSS.""" + kubeconfig = get_kubeconfig() lightkube_client = Client(config=kubeconfig) return lightkube_client def get_mlflow_tracking_uri() -> str: + """Returns the MLflow tracking URI for the DSS deployment.""" return f"http://{MLFLOW_DEPLOYMENT_NAME}.{DSS_NAMESPACE}.svc.cluster.local:5000" diff --git a/tests/integration/test_dss.py b/tests/integration/test_dss.py index 360f21f..c2e6a87 100644 --- a/tests/integration/test_dss.py +++ b/tests/integration/test_dss.py @@ -1,4 +1,6 @@ +import os import subprocess +from pathlib import Path import lightkube import pytest @@ -8,11 +10,26 @@ from lightkube.resources.core_v1 import Namespace, PersistentVolumeClaim, Pod, Service from dss.config import DSS_CLI_MANAGER_LABELS, DSS_NAMESPACE, FIELD_MANAGER, NOTEBOOK_LABEL +from dss.utils import KUBECONFIG_DEFAULT, KUBECONFIG_ENV_VAR # TODO: is there a better way to initialize this? Maybe an optional argument to the test? -KUBECONFIG = "~/.kube/config" NOTEBOOK_RESOURCES_FILE = "./tests/integration/notebook-resources.yaml" NOTEBOOK_NAME = "test-nb" +# Path to the kubeconfig for the host's kubernetes cluster used for testing +KUBECONFIG_PATH_FOR_TEST = Path("~/.kube/config").expanduser() + + +@pytest.fixture() +def set_dss_kubeconfig_environment_variable(monkeypatch): + """Sets the DSS_KUBECONFIG environment variable to ~/.kube/config unless it is already set. + + Yields the value of the environment variable, for convenience.""" + if not os.environ.get(KUBECONFIG_ENV_VAR): + with pytest.MonkeyPatch.context() as mp: + mp.setenv(KUBECONFIG_ENV_VAR, str(KUBECONFIG_PATH_FOR_TEST)) + yield os.environ.get(KUBECONFIG_ENV_VAR) + else: + yield os.environ.get(KUBECONFIG_ENV_VAR) @pytest.mark.parametrize( @@ -22,15 +39,19 @@ pytest.param("gpu", marks=pytest.mark.gpu), ], ) -def test_status_before_initialize(is_cpu_or_gpu, cleanup_after_initialize) -> None: +def test_status_before_initialize( + is_cpu_or_gpu, set_dss_kubeconfig_environment_variable, cleanup_after_initialize +) -> None: """ Integration test to verify 'dss status' command before initialization. + + Unlike other tests below, this test gets its kubeconfig from a path specified by an environment + variable. This is both by necessity (bevcause initialize has not run yet) and also to test + whether the environment variable mechanism works. """ # Run the status command - result = subprocess.run( - ["dss", "status", "--kubeconfig", KUBECONFIG], capture_output=True, text=True - ) + result = subprocess.run(["dss", "status"], capture_output=True, text=True) # Check if the command executed successfully assert result.returncode == 0 @@ -57,9 +78,14 @@ def test_initialize_creates_dss(cleanup_after_initialize) -> None: """ Integration test to verify if the initialize command creates the 'dss' namespace and the 'mlflow' deployment is active in the 'dss' namespace. + + Note that this test requires an existing kubeconfig file exist at ~/.kube/config, and it stores + this kubeconfig for all other tests below. """ + kubeconfig_text = KUBECONFIG_PATH_FOR_TEST.read_text() + result = subprocess.run( - ["dss", "initialize", "--kubeconfig", KUBECONFIG], + ["dss", "initialize", "--kubeconfig", kubeconfig_text], capture_output=True, text=True, ) @@ -100,8 +126,7 @@ def test_create_notebook(cleanup_after_initialize, notebook_image) -> None: Must be run after `dss initialize` """ - kubeconfig = lightkube.KubeConfig.from_file(KUBECONFIG) - lightkube_client = lightkube.Client(kubeconfig) + lightkube_client = get_lightkube_client() result = subprocess.run( [ @@ -110,8 +135,6 @@ def test_create_notebook(cleanup_after_initialize, notebook_image) -> None: NOTEBOOK_NAME, "--image", notebook_image, - "--kubeconfig", - KUBECONFIG, ], capture_output=True, text=True, @@ -132,8 +155,7 @@ def test_notebook_gpu_availability(cleanup_after_initialize): """ Test to ensure that the GPU is available in the deployed Jupyter notebook. """ - kubeconfig = lightkube.KubeConfig.from_file(KUBECONFIG) - lightkube_client = lightkube.Client(kubeconfig) + lightkube_client = get_lightkube_client() deployment = lightkube_client.get(Deployment, name=NOTEBOOK_NAME, namespace=DSS_NAMESPACE) assert deployment.status.availableReplicas == deployment.spec.replicas @@ -166,8 +188,6 @@ def test_list_after_create(cleanup_after_initialize) -> None: [ "dss", "list", - "--kubeconfig", - KUBECONFIG, "--wide", ], capture_output=True, @@ -193,11 +213,8 @@ def test_status_after_initialize(is_cpu_or_gpu, cleanup_after_initialize) -> Non """ Integration test to verify 'dss status' command after initialization. """ - # Run the status command - result = subprocess.run( - ["dss", "status", "--kubeconfig", KUBECONFIG], capture_output=True, text=True - ) + result = subprocess.run(["dss", "status"], capture_output=True, text=True) # Check if the command executed successfully assert result.returncode == 0 @@ -231,8 +248,6 @@ def test_log_command(cleanup_after_initialize) -> None: "dss", "logs", NOTEBOOK_NAME, - "--kubeconfig", - KUBECONFIG, ], capture_output=True, text=True, @@ -246,7 +261,7 @@ def test_log_command(cleanup_after_initialize) -> None: # Run the logs command for MLflow with the kubeconfig file result = subprocess.run( - ["dss", "logs", "--mlflow", "--kubeconfig", KUBECONFIG], + ["dss", "logs", "--mlflow"], capture_output=True, text=True, ) @@ -266,8 +281,7 @@ def test_stop_notebook(cleanup_after_initialize) -> None: Must be run after `dss create`. """ - kubeconfig = lightkube.KubeConfig.from_file(KUBECONFIG) - lightkube_client = lightkube.Client(kubeconfig) + lightkube_client = get_lightkube_client() # Run the stop command with the notebook name and kubeconfig file result = subprocess.run( @@ -275,8 +289,6 @@ def test_stop_notebook(cleanup_after_initialize) -> None: "dss", "stop", NOTEBOOK_NAME, - "--kubeconfig", - KUBECONFIG, ], capture_output=True, text=True, @@ -298,8 +310,7 @@ def test_start_notebook(cleanup_after_initialize) -> None: Must be run after `dss create` and `dss stop`. """ - kubeconfig = lightkube.KubeConfig.from_file(KUBECONFIG) - lightkube_client = lightkube.Client(kubeconfig) + lightkube_client = get_lightkube_client() # Run the start command with the notebook name and kubeconfig file result = subprocess.run( @@ -307,8 +318,6 @@ def test_start_notebook(cleanup_after_initialize) -> None: "dss", "start", NOTEBOOK_NAME, - "--kubeconfig", - KUBECONFIG, ], capture_output=True, text=True, @@ -329,18 +338,13 @@ def test_remove_notebook(cleanup_after_initialize) -> None: Tests that `dss remove` successfully removes a notebook as expected. Must be run after `dss initialize` """ - # FIXME: remove the `--kubeconfig`` option - # after fixing https://github.com/canonical/data-science-stack/issues/37 - kubeconfig = lightkube.KubeConfig.from_file(KUBECONFIG) - lightkube_client = lightkube.Client(kubeconfig) + lightkube_client = get_lightkube_client() result = subprocess.run( [ DSS_NAMESPACE, "remove", NOTEBOOK_NAME, - "--kubeconfig", - KUBECONFIG, ] ) assert result.returncode == 0 @@ -368,8 +372,6 @@ def test_purge(cleanup_after_initialize) -> None: [ "dss", "purge", - "--kubeconfig", - KUBECONFIG, ], capture_output=True, text=True, @@ -383,8 +385,7 @@ def test_purge(cleanup_after_initialize) -> None: ) # Check that namespace has been deleted - kubeconfig = lightkube.KubeConfig.from_file(KUBECONFIG) - lightkube_client = lightkube.Client(kubeconfig) + lightkube_client = get_lightkube_client() with pytest.raises(ApiError) as err: lightkube_client.get(Namespace, name=DSS_NAMESPACE) assert str(err.value) == 'namespaces "dss" not found' @@ -414,7 +415,17 @@ def cleanup_after_initialize(): # the tests quickly can still cause an issue k8s_resource_handler.delete() + # Clean up our kubeconfig cache file + if os.path.exists(KUBECONFIG_DEFAULT): + os.remove(KUBECONFIG_DEFAULT) + def get_pod_name_from_deployment(client, deployment_name, namespace): pods = list(client.list(Pod, namespace=namespace, labels={NOTEBOOK_LABEL: deployment_name})) return pods[0].metadata.name if pods else None + + +def get_lightkube_client(kubeconfig_path: str = KUBECONFIG_DEFAULT) -> lightkube.Client: + """Returns a lightkube.Client using a kubeconfig from the specified location.""" + kubeconfig = lightkube.KubeConfig.from_file(kubeconfig_path) + return lightkube.Client(kubeconfig) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index a975ae0..7c1c942 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,5 +1,6 @@ from contextlib import nullcontext as does_not_raise -from unittest.mock import MagicMock, patch +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch import pytest from lightkube import ApiError @@ -9,17 +10,18 @@ from dss.config import DSS_NAMESPACE, MLFLOW_DEPLOYMENT_NAME, DeploymentState from dss.utils import ( - KUBECONFIG_DEFAULT, ImagePullBackOffError, does_dss_pvc_exist, does_namespace_exist, does_notebook_exist, - get_default_kubeconfig, get_deployment_state, + get_kubeconfig, + get_kubeconfig_path, get_labels_for_node, get_lightkube_client, get_mlflow_tracking_uri, get_service_url, + save_kubeconfig, wait_for_deployment_ready, wait_for_namespace_to_be_deleted, ) @@ -58,6 +60,15 @@ def mock_kubeconfig() -> MagicMock: yield mock_kubeconfig +@pytest.fixture +def mock_get_kubeconfig() -> MagicMock: + """ + Fixture to mock the KubeConfig.from_dict function. + """ + with patch("dss.utils.get_kubeconfig") as mock_get_kubeconfig: + yield mock_get_kubeconfig + + @pytest.fixture def mock_logger() -> MagicMock: """ @@ -102,49 +113,83 @@ def __init__(self, code=400): super().__init__(response=_FakeResponse(code)) +@patch("dss.utils.get_kubeconfig_path") +def test_get_kubeconfig( + mock_get_kubeconfig_path, + mock_kubeconfig, + monkeypatch, +): + """Tests that get_kubeconfig succeeds as expected.""" + # Arrange + mock_get_kubeconfig_path.return_value = Path("kubeconfig-path") + + # Act + _ = get_kubeconfig() + + # Assert + mock_kubeconfig.from_file.assert_called_once_with(mock_get_kubeconfig_path.return_value) + + @pytest.mark.parametrize( - "kubeconfig, kubeconfig_env_var, expected", + "env_var_content, kubeconfig_default, expected_kubeconfig_path_used", [ - ("some_file", "", "some_file"), - (None, "some_file", "some_file"), - (None, "", KUBECONFIG_DEFAULT), + ("env_var_path", "default_path", Path("env_var_path")), + (None, "default_path", Path("default_path")), ], ) -def test_get_default_kubeconfig_successful( - kubeconfig: str, - kubeconfig_env_var: str, - expected: str, - mock_environ_get: MagicMock, -) -> None: - """ - Test case to verify missing kubeconfig environment variable. +def test_get_kubeconfig_path( + env_var_content: str, + kubeconfig_default: str, + expected_kubeconfig_path_used: Path, + monkeypatch, +): + """Test that get_kubeconfig_path correctly returns the default path and one from an env var.""" + # Arrange + env_var = "test-env-var" + + with pytest.MonkeyPatch.context() as mp: + if env_var_content is None: + mp.delenv(env_var, raising=False) + else: + mp.setenv(env_var, env_var_content) + + # Act + actual = get_kubeconfig_path( + env_var=env_var, default_kubeconfig_location=kubeconfig_default + ) + + # Assert + assert actual == expected_kubeconfig_path_used - Args: - kubeconfig: path to a kubeconfig file, passed to get_lightkube_client by arg - kubeconfig_env_var: environment variable for kubeconfig - expected: expected returned value for kubeconfig - """ - mock_environ_get.return_value = kubeconfig_env_var - returned = get_default_kubeconfig(kubeconfig) - assert returned == expected +@patch("dss.utils.get_kubeconfig_path") +def test_save_kubeconfig( + mock_get_kubeconfig_path: MagicMock, + monkeypatch, +): + """Test that save_kubeconfig succeeds as expected.""" + # Arrange + kubeconfig = "kubeconfig-text" + + # Act + mocked_open = mock_open() + with patch("dss.utils.open", mocked_open): + save_kubeconfig(kubeconfig) + + # Assert + mock_get_kubeconfig_path.return_value.parent.mkdir.assert_called_once() + mocked_open.assert_called_once_with(mock_get_kubeconfig_path.return_value, "w") + mocked_open().write.assert_called_once_with(kubeconfig) def test_get_lightkube_client_successful( - mock_kubeconfig: MagicMock, + mock_get_kubeconfig: MagicMock, mock_client: MagicMock, ) -> None: """ Tests that we successfully try to create a lightkube client, given a kubeconfig. """ - kubeconfig = "some_file" - mock_kubeconfig_instance = "kubeconfig-returned" - mock_kubeconfig.from_file.return_value = mock_kubeconfig_instance - - returned_client = get_lightkube_client(kubeconfig) - - mock_kubeconfig.from_file.assert_called_with(kubeconfig) - mock_client.assert_called_with(config=mock_kubeconfig_instance) + returned_client = get_lightkube_client() assert returned_client is not None diff --git a/tox.ini b/tox.ini index 07f8c49..f6a6a5b 100644 --- a/tox.ini +++ b/tox.ini @@ -111,7 +111,9 @@ deps = description = Run GPU-based integration tests [testenv:dss-cli] -description = Run simple integration tests using the dss executable. This needs an existing microk8s cluster, and must be run with `tox -e dss -- --kubeconfig=PATH` +description = Run simple integration tests using the dss executable. This needs an existing microk8s cluster with a kubeconfig file located at ~/.dss/config. Override the default kubeconfig location by setting DSS_KUBECONFIG allowlist_externals = dss +pass_env = + DSS_KUBECONFIG commands = - dss status {posargs} + dss status