diff --git a/src/codeflare_sdk/cli/cli_utils.py b/src/codeflare_sdk/cli/cli_utils.py index 7152cc397..0c557a8ea 100644 --- a/src/codeflare_sdk/cli/cli_utils.py +++ b/src/codeflare_sdk/cli/cli_utils.py @@ -1,5 +1,12 @@ import ast import click +from kubernetes import client, config +import pickle +import os + +from codeflare_sdk.cluster.auth import _create_api_client_config +from codeflare_sdk.utils.kube_api_helpers import _kube_api_error_handling +import codeflare_sdk.cluster.auth as sdk_auth class PythonLiteralOption(click.Option): @@ -10,3 +17,43 @@ def type_cast_value(self, ctx, value): return ast.literal_eval(value) except: raise click.BadParameter(value) + + +class AuthenticationConfig: + """ + Authentication configuration that will be stored in a file once + the user logs in using `codeflare login` + """ + + def __init__( + self, + token: str, + server: str, + skip_tls: bool, + ca_cert_path: str, + ): + self.api_client_config = _create_api_client_config( + token, server, skip_tls, ca_cert_path + ) + self.server = server + self.token = token + + def create_client(self): + return client.ApiClient(self.api_client_config) + + +def load_auth(): + """ + Loads AuthenticationConfiguration and stores it in global variables + which can be used by the SDK for authentication + """ + try: + auth_file_path = os.path.expanduser("~/.codeflare/auth") + with open(auth_file_path, "rb") as file: + auth = pickle.load(file) + sdk_auth.api_client = auth.create_client() + return auth + except (IOError, EOFError): + click.echo("No authentication found, trying default kubeconfig") + except client.ApiException: + click.echo("Invalid authentication, trying default kubeconfig") diff --git a/src/codeflare_sdk/cli/codeflare_cli.py b/src/codeflare_sdk/cli/codeflare_cli.py index 3083a40d0..f8a5cbab7 100644 --- a/src/codeflare_sdk/cli/codeflare_cli.py +++ b/src/codeflare_sdk/cli/codeflare_cli.py @@ -5,6 +5,11 @@ cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands")) +class CodeflareContext: + def __init__(self, codeflare_path): + self.codeflare_path = codeflare_path + + class CodeflareCLI(click.MultiCommand): def list_commands(self, ctx): rv = [] @@ -26,9 +31,18 @@ def get_command(self, ctx, name): return +def initialize_cli(ctx): + # Make .codeflare folder + codeflare_folder = os.path.expanduser("~/.codeflare") + if not os.path.exists(codeflare_folder): + os.makedirs(codeflare_folder) + ctx.obj = CodeflareContext(codeflare_folder) + + @click.command(cls=CodeflareCLI) @click.pass_context def cli(ctx): + initialize_cli(ctx) # Ran on every command pass diff --git a/src/codeflare_sdk/cli/commands/login.py b/src/codeflare_sdk/cli/commands/login.py new file mode 100644 index 000000000..288607a89 --- /dev/null +++ b/src/codeflare_sdk/cli/commands/login.py @@ -0,0 +1,46 @@ +import click +import pickle +from kubernetes import client +import os + +from codeflare_sdk.cluster.auth import TokenAuthentication +from codeflare_sdk.cli.cli_utils import AuthenticationConfig +import codeflare_sdk.cluster.auth as sdk_auth + + +@click.command() +@click.pass_context +@click.option("--server", "-s", type=str, required=True, help="Cluster API address") +@click.option("--token", "-t", type=str, required=True, help="Authentication token") +@click.option( + "--insecure-skip-tls-verify", + type=bool, + help="If true, server's certificate won't be checked for validity", + default=False, +) +@click.option( + "--certificate-authority", + type=str, + help="Path to cert file for certificate authority", +) +def cli(ctx, server, token, insecure_skip_tls_verify, certificate_authority): + """ + Login to your Kubernetes cluster and save login for subsequent use + """ + auth = TokenAuthentication( + token, server, insecure_skip_tls_verify, certificate_authority + ) + auth.login() + if not sdk_auth.api_client: # TokenAuthentication failed + return + + auth_config = AuthenticationConfig( + token, + server, + insecure_skip_tls_verify, + certificate_authority, + ) + auth_file_path = ctx.obj.codeflare_path + "/auth" + with open(auth_file_path, "wb") as file: + pickle.dump(auth_config, file) + click.echo(f"Logged into '{server}'") diff --git a/src/codeflare_sdk/cli/commands/logout.py b/src/codeflare_sdk/cli/commands/logout.py new file mode 100644 index 000000000..0001b2331 --- /dev/null +++ b/src/codeflare_sdk/cli/commands/logout.py @@ -0,0 +1,19 @@ +import click +import os +import pickle + + +@click.command() +@click.pass_context +def cli(ctx): + """ + Log out of current Kubernetes cluster + """ + try: + auth_file_path = ctx.obj.codeflare_path + "/auth" + with open(auth_file_path, "rb") as file: + auth = pickle.load(file) + os.remove(auth_file_path) + click.echo(f"Successfully logged out of '{auth.server}'") + except: + click.echo("Not logged in") diff --git a/src/codeflare_sdk/cluster/auth.py b/src/codeflare_sdk/cluster/auth.py index 85db3d61d..90c1f726a 100644 --- a/src/codeflare_sdk/cluster/auth.py +++ b/src/codeflare_sdk/cluster/auth.py @@ -97,17 +97,11 @@ def login(self) -> str: global config_path global api_client try: - configuration = client.Configuration() - configuration.api_key_prefix["authorization"] = "Bearer" - configuration.host = self.server - configuration.api_key["authorization"] = self.token - if self.skip_tls == False and self.ca_cert_path == None: - configuration.verify_ssl = True - elif self.skip_tls == False: - configuration.ssl_ca_cert = self.ca_cert_path - else: - configuration.verify_ssl = False - api_client = client.ApiClient(configuration) + api_client = client.ApiClient( + _create_api_client_config( + self.token, self.server, self.skip_tls, self.ca_cert_path + ) + ) client.AuthenticationApi(api_client).get_api_group() config_path = None return "Logged into %s" % self.server @@ -154,6 +148,25 @@ def load_kube_config(self): return response +def _create_api_client_config( + token: str, server: str, skip_tls: bool = False, ca_cert_path: str = None +): + """ + Creates Kubernetes client configuration given necessary parameters + """ + configuration = client.Configuration() + configuration.api_key_prefix["authorization"] = "Bearer" + configuration.host = server + configuration.api_key["authorization"] = token + if skip_tls == False and ca_cert_path == None: + configuration.verify_ssl = True + elif skip_tls == False: + configuration.ssl_ca_cert = ca_cert_path + else: + configuration.verify_ssl = False + return configuration + + def config_check() -> str: """ Function for loading the config file at the default config location ~/.kube/config if the user has not diff --git a/tests/unit_test.py b/tests/unit_test.py index 9eddd3cfc..de202b518 100644 --- a/tests/unit_test.py +++ b/tests/unit_test.py @@ -18,6 +18,7 @@ import os import re from click.testing import CliRunner +import pickle parent = Path(__file__).resolve().parents[1] sys.path.append(str(parent) + "/src") @@ -65,6 +66,8 @@ export_env, ) from codeflare_sdk.cli.codeflare_cli import cli +from codeflare_sdk.cli.cli_utils import load_auth +import codeflare_sdk.cluster.auth as sdk_auth import openshift from openshift.selector import Selector @@ -108,6 +111,65 @@ def test_cluster_definition_cli(): ) +def test_login_cli(mocker): + runner = CliRunner() + mocker.patch.object(client, "ApiClient") + k8s_login_command = """ + login + --server=testserver:6443 + --token=testtoken + """ + login_result = runner.invoke(cli, k8s_login_command) + assert login_result.output == "Logged into 'testserver:6443'\n" + try: + auth_file_path = os.path.expanduser("~/.codeflare/auth") + with open(auth_file_path, "rb") as file: + auth = pickle.load(file) + except: + assert 0 == 1 + assert auth.server == "testserver:6443" + assert auth.token == "testtoken" + assert auth.api_client_config.api_key["authorization"] == "testtoken" + assert auth.api_client_config.verify_ssl + assert auth.api_client_config.host == "testserver:6443" + + +def test_login_tls_cli(mocker): + runner = CliRunner() + mocker.patch.object(client, "ApiClient") + k8s_tls_login_command = """ + login + --server=testserver:6443 + --token=testtoken + --insecure-skip-tls-verify=False + """ + k8s_skip_tls_login_command = """ + login + --server=testserver:6443 + --token=testtoken + --insecure-skip-tls-verify=True + """ + tls_result = runner.invoke(cli, k8s_tls_login_command) + skip_tls_result = runner.invoke(cli, k8s_skip_tls_login_command) + assert ( + tls_result.output == skip_tls_result.output == "Logged into 'testserver:6443'\n" + ) + + +def test_logout_cli(mocker): + runner = CliRunner() + mocker.patch.object(client, "ApiClient") + k8s_logout_command = "logout" + logout_result = runner.invoke(cli, k8s_logout_command) + assert logout_result.output == "Successfully logged out of 'testserver:6443'\n" + assert not os.path.exists(os.path.expanduser("~/.codeflare/auth")) + + +def test_load_auth(): + load_auth() + assert sdk_auth.api_client is not None + + # For mocking openshift client results fake_res = openshift.Result("fake") @@ -2255,3 +2317,4 @@ def test_cleanup(): os.remove("test.yaml") os.remove("raytest2.yaml") os.remove("cli-test-cluster.yaml") + os.removedirs(os.path.expanduser("~/.codeflare"))