diff --git a/demo-notebooks/additional-demos/hf_interactive.ipynb b/demo-notebooks/additional-demos/hf_interactive.ipynb index f5884667d..6ced12112 100644 --- a/demo-notebooks/additional-demos/hf_interactive.ipynb +++ b/demo-notebooks/additional-demos/hf_interactive.ipynb @@ -40,8 +40,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -60,7 +60,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -76,8 +76,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/additional-demos/local_interactive.ipynb b/demo-notebooks/additional-demos/local_interactive.ipynb index 5a78271ff..565a97122 100644 --- a/demo-notebooks/additional-demos/local_interactive.ipynb +++ b/demo-notebooks/additional-demos/local_interactive.ipynb @@ -10,8 +10,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -30,7 +30,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -46,8 +46,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/additional-demos/ray_job_client.ipynb b/demo-notebooks/additional-demos/ray_job_client.ipynb index 72173cd65..723a447f9 100644 --- a/demo-notebooks/additional-demos/ray_job_client.ipynb +++ b/demo-notebooks/additional-demos/ray_job_client.ipynb @@ -14,8 +14,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, RayJobClient, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration, RayJobClient\n", + "from kube_authkit import AuthConfig" ] }, { @@ -33,7 +33,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "# Note: auth_token is also used below as the bearer token for the RayJobClient\n", "auth_token = \"sha256~XXXXX\" # oc whoami -t\n", "auth_config = AuthConfig(\n", @@ -52,8 +52,7 @@ "# )\n", "# auth_token = ... # Retrieve access token from your OIDC provider for RayJobClient\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/0_basic_ray.ipynb b/demo-notebooks/guided-demos/0_basic_ray.ipynb index 0527c3147..14d081def 100644 --- a/demo-notebooks/guided-demos/0_basic_ray.ipynb +++ b/demo-notebooks/guided-demos/0_basic_ray.ipynb @@ -19,8 +19,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -39,7 +39,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -55,8 +55,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/1_cluster_job_client.ipynb b/demo-notebooks/guided-demos/1_cluster_job_client.ipynb index 9da368a43..305acacde 100644 --- a/demo-notebooks/guided-demos/1_cluster_job_client.ipynb +++ b/demo-notebooks/guided-demos/1_cluster_job_client.ipynb @@ -14,8 +14,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -33,7 +33,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -49,8 +49,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/2_basic_interactive.ipynb b/demo-notebooks/guided-demos/2_basic_interactive.ipynb index 0e73176bd..6625c5fae 100644 --- a/demo-notebooks/guided-demos/2_basic_interactive.ipynb +++ b/demo-notebooks/guided-demos/2_basic_interactive.ipynb @@ -16,8 +16,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -36,7 +36,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -52,8 +52,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/3_widget_example.ipynb b/demo-notebooks/guided-demos/3_widget_example.ipynb index d2d43d1a4..d22bfddf9 100644 --- a/demo-notebooks/guided-demos/3_widget_example.ipynb +++ b/demo-notebooks/guided-demos/3_widget_example.ipynb @@ -19,8 +19,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, view_clusters, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration, view_clusters\n", + "from kube_authkit import AuthConfig" ] }, { @@ -39,7 +39,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -55,8 +55,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/6_single_entrypoint.ipynb b/demo-notebooks/guided-demos/6_single_entrypoint.ipynb new file mode 100644 index 000000000..c6dbecc42 --- /dev/null +++ b/demo-notebooks/guided-demos/6_single_entrypoint.ipynb @@ -0,0 +1,300 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "# Single Entrypoint: The `Codeflare` Class\n", + "\n", + "This notebook demonstrates the new single entrypoint for the CodeFlare SDK.\n", + "Instead of managing authentication and configuration separately, everything\n", + "goes through one object:\n", + "\n", + "```python\n", + "cf = Codeflare(config=SDKConfig(auth=..., namespace=...))\n", + "```\n", + "\n", + "From there you use **handlers** to work with clusters and jobs:\n", + "- `cf.clusters.create(...)`, `cf.clusters.get(...)`, `cf.clusters.list()`\n", + "- `cf.jobs.submit(...)`, `cf.jobs.create(...)`\n", + "\n", + "We will:\n", + "1. Authenticate and create the `Codeflare` entrypoint\n", + "2. Create and bring up a Ray cluster via `cf.clusters`\n", + "3. Submit a RayJob CR via `cf.jobs`\n", + "4. Clean up" + ] + }, + { + "cell_type": "markdown", + "id": "b1b2c3d4", + "metadata": {}, + "source": [ + "## Step 1: Authenticate and create the entrypoint\n", + "\n", + "Import `Codeflare` and `SDKConfig` from the SDK, and `AuthConfig` from kube-authkit.\n", + "Choose the authentication method that matches your environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "from codeflare_sdk import Codeflare, SDKConfig\n", + "from kube_authkit import AuthConfig" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "# --- Pick ONE auth method and uncomment it ---\n", + "\n", + "# Option 1: Auto-detect (kubeconfig or in-cluster service account)\n", + "# Works locally if you have ~/.kube/config, or inside a pod with a service account.\n", + "# auth_config = AuthConfig(method=\"auto\")\n", + "\n", + "# Option 2: Token-based (recommended for RHOAI Workbenches)\n", + "# Get your token: oc whoami -t\n", + "auth_config = AuthConfig(\n", + " method=\"openshift\",\n", + " k8s_api_host=\"https://api.example.com:6443\",\n", + " token=\"sha256~XXXXX\", # oc whoami -t\n", + ")\n", + "\n", + "# Option 3: OIDC (for BYOIDC-enabled clusters)\n", + "# auth_config = AuthConfig(\n", + "# method=\"oidc\",\n", + "# k8s_api_host=\"https://api.example.com:6443\",\n", + "# oidc_issuer=\"https://your-oidc-provider.com\",\n", + "# client_id=\"your-client-id\",\n", + "# use_device_flow=True,\n", + "# )\n", + "\n", + "# Create the entrypoint — this authenticates and sets up the SDK\n", + "cf = Codeflare(config=SDKConfig(\n", + " auth=auth_config,\n", + " namespace=\"your-namespace\", # default namespace for all operations\n", + " log_level=\"INFO\",\n", + "))\n", + "\n", + "print(f\"Connected. Default namespace: {cf.config.namespace}\")" + ] + }, + { + "cell_type": "markdown", + "id": "e1b2c3d4", + "metadata": {}, + "source": [ + "## Step 2: List existing clusters\n", + "\n", + "Use `cf.clusters.list()` to see what Ray clusters are already running.\n", + "The namespace defaults to the one you set in `SDKConfig`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "cf.clusters.list()" + ] + }, + { + "cell_type": "markdown", + "id": "g1b2c3d4", + "metadata": {}, + "source": [ + "## Step 3: Create and bring up a Ray cluster\n", + "\n", + "Use `cf.clusters.create()` to build a `Cluster` object. All keyword arguments\n", + "are forwarded to `ClusterConfiguration`. The namespace is inherited from\n", + "`SDKConfig` unless you override it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "h1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "cluster = cf.clusters.create(\n", + " name=\"entrypoint-demo\",\n", + " num_workers=2,\n", + " head_cpu_requests=\"500m\",\n", + " head_cpu_limits=\"500m\",\n", + " head_memory_requests=5,\n", + " head_memory_limits=8,\n", + " worker_cpu_requests=\"250m\",\n", + " worker_cpu_limits=1,\n", + " worker_memory_requests=4,\n", + " worker_memory_limits=6,\n", + " head_extended_resource_requests={\"nvidia.com/gpu\": 0},\n", + " worker_extended_resource_requests={\"nvidia.com/gpu\": 0},\n", + " write_to_file=False,\n", + " # local_queue=\"your-local-queue\", # optional — auto-detected if omitted\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "i1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "cluster.apply()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "j1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "cluster.wait_ready()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "k1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "cluster.details()" + ] + }, + { + "cell_type": "markdown", + "id": "l1b2c3d4", + "metadata": {}, + "source": [ + "## Step 4: Submit a RayJob CR via `cf.jobs`\n", + "\n", + "Use `cf.jobs.submit()` to create and immediately submit a RayJob.\n", + "The job runs on a short-lived managed cluster (Kuberay handles lifecycle)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "from codeflare_sdk import ManagedClusterConfig\n", + "\n", + "job = cf.jobs.submit(\n", + " name=\"entrypoint-demo-job\",\n", + " entrypoint=\"python -c 'import ray; ray.init(); print(\\\"Hello from the Codeflare entrypoint!\\\")'\",\n", + " cluster_config=ManagedClusterConfig(\n", + " head_memory_requests=6,\n", + " head_memory_limits=8,\n", + " num_workers=1,\n", + " worker_cpu_requests=1,\n", + " worker_cpu_limits=1,\n", + " worker_memory_requests=4,\n", + " worker_memory_limits=6,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "n1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "# Check job status (may show 'unknown' while the managed cluster spins up)\n", + "job.status()" + ] + }, + { + "cell_type": "markdown", + "id": "o1b2c3d4", + "metadata": {}, + "source": [ + "## Step 5: Retrieve an existing cluster\n", + "\n", + "`cf.clusters.get()` fetches a running cluster by name — useful when\n", + "reconnecting to a cluster you created earlier." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "p1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "same_cluster = cf.clusters.get(name=\"entrypoint-demo\")\n", + "same_cluster.status()" + ] + }, + { + "cell_type": "markdown", + "id": "q1b2c3d4", + "metadata": {}, + "source": [ + "## Step 6: Clean up\n", + "\n", + "Bring down the cluster. The managed RayJob cluster is cleaned up\n", + "automatically by Kuberay when the job completes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "r1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "cluster.down()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s1b2c3d4", + "metadata": {}, + "outputs": [], + "source": [ + "# No explicit logout needed — authentication is managed by kube-authkit" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbformat_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/demo-notebooks/guided-demos/notebook-ex-outputs/0_basic_ray.ipynb b/demo-notebooks/guided-demos/notebook-ex-outputs/0_basic_ray.ipynb index 7dbf59bfc..217919591 100644 --- a/demo-notebooks/guided-demos/notebook-ex-outputs/0_basic_ray.ipynb +++ b/demo-notebooks/guided-demos/notebook-ex-outputs/0_basic_ray.ipynb @@ -19,8 +19,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -39,7 +39,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -55,8 +55,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/notebook-ex-outputs/1_cluster_job_client.ipynb b/demo-notebooks/guided-demos/notebook-ex-outputs/1_cluster_job_client.ipynb index d582495e2..b69149a58 100644 --- a/demo-notebooks/guided-demos/notebook-ex-outputs/1_cluster_job_client.ipynb +++ b/demo-notebooks/guided-demos/notebook-ex-outputs/1_cluster_job_client.ipynb @@ -14,8 +14,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -33,7 +33,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -49,8 +49,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/notebook-ex-outputs/2_basic_interactive.ipynb b/demo-notebooks/guided-demos/notebook-ex-outputs/2_basic_interactive.ipynb index 2198fdb46..79572d672 100644 --- a/demo-notebooks/guided-demos/notebook-ex-outputs/2_basic_interactive.ipynb +++ b/demo-notebooks/guided-demos/notebook-ex-outputs/2_basic_interactive.ipynb @@ -16,8 +16,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -36,7 +36,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -52,8 +52,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/preview_nbs/0_basic_ray.ipynb b/demo-notebooks/guided-demos/preview_nbs/0_basic_ray.ipynb index 7dbf59bfc..217919591 100644 --- a/demo-notebooks/guided-demos/preview_nbs/0_basic_ray.ipynb +++ b/demo-notebooks/guided-demos/preview_nbs/0_basic_ray.ipynb @@ -19,8 +19,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -39,7 +39,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -55,8 +55,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/preview_nbs/1_cluster_job_client.ipynb b/demo-notebooks/guided-demos/preview_nbs/1_cluster_job_client.ipynb index 8d980954c..e7508278b 100644 --- a/demo-notebooks/guided-demos/preview_nbs/1_cluster_job_client.ipynb +++ b/demo-notebooks/guided-demos/preview_nbs/1_cluster_job_client.ipynb @@ -14,8 +14,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -33,7 +33,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -49,8 +49,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/preview_nbs/2_basic_interactive.ipynb b/demo-notebooks/guided-demos/preview_nbs/2_basic_interactive.ipynb index 6c41861f6..fba31bbe7 100644 --- a/demo-notebooks/guided-demos/preview_nbs/2_basic_interactive.ipynb +++ b/demo-notebooks/guided-demos/preview_nbs/2_basic_interactive.ipynb @@ -16,8 +16,8 @@ "outputs": [], "source": [ "# Import pieces from codeflare-sdk\n", - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -36,7 +36,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -52,8 +52,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/demo-notebooks/guided-demos/preview_nbs/4_rayjob_existing_cluster.ipynb b/demo-notebooks/guided-demos/preview_nbs/4_rayjob_existing_cluster.ipynb index 1300d80a7..3c2bdec40 100644 --- a/demo-notebooks/guided-demos/preview_nbs/4_rayjob_existing_cluster.ipynb +++ b/demo-notebooks/guided-demos/preview_nbs/4_rayjob_existing_cluster.ipynb @@ -37,8 +37,8 @@ "metadata": {}, "outputs": [], "source": [ - "from codeflare_sdk import Cluster, ClusterConfiguration, set_api_client\n", - "from kube_authkit import AuthConfig, get_k8s_client" + "from codeflare_sdk import Codeflare, SDKConfig, Cluster, ClusterConfiguration\n", + "from kube_authkit import AuthConfig" ] }, { @@ -65,7 +65,7 @@ "# auth_config = AuthConfig(method=\"auto\")\n", "\n", "# Option 2 (Recommended for RHOAI Workbenches): Token-based authentication\n", - "# Get your token with: oc whoami -t (or from the OpenShift console → Copy login command)\n", + "# Get your token with: oc whoami -t (or from the OpenShift console \u2192 Copy login command)\n", "auth_config = AuthConfig(\n", " method=\"openshift\",\n", " k8s_api_host=\"https://api.example.com:6443\",\n", @@ -81,8 +81,7 @@ "# use_device_flow=True, # Interactive device flow for notebook environments\n", "# )\n", "\n", - "api_client = get_k8s_client(config=auth_config)\n", - "set_api_client(api_client)" + "cf = Codeflare(config=SDKConfig(auth=auth_config))\n" ] }, { diff --git a/docs/superpowers/specs/2026-05-15-single-entrypoint-design.md b/docs/superpowers/specs/2026-05-15-single-entrypoint-design.md new file mode 100644 index 000000000..b10669c08 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-single-entrypoint-design.md @@ -0,0 +1,253 @@ +# Single Entrypoint Design: `Codeflare` Class + +**Date**: 2026-05-15 +**Status**: Draft +**Author**: Saad Zaher + +## Overview + +Introduce a single `Codeflare` class as the primary entrypoint for the SDK. It owns authentication (exclusively via kube-authkit), SDK-level configuration, and provides namespace-accessor handlers for clusters and jobs. + +## Goals + +1. Single entrypoint: `cf = Codeflare(config=SDKConfig(...))` +2. Handler pattern: `cf.clusters.*` and `cf.jobs.*` +3. Authentication via kube-authkit only — remove all legacy auth classes +4. Coexist with existing `Cluster`/`RayJob` classes (they become return types from handlers) +5. Default namespace, retries, timeout, and logging configured once at SDK level + +## Non-Goals + +- Dependency injection into `Cluster`/`RayJob` internals (future improvement) +- Multi-client support (multiple `Codeflare` instances with different credentials) +- Breaking changes to `Cluster`, `ClusterConfiguration`, `RayJob`, or `ManagedClusterConfig` + +## Architecture + +``` +Codeflare(config=SDKConfig) +├── .config: SDKConfig +│ ├── auth: kube_authkit.AuthConfig +│ ├── retries: int +│ ├── timeout: int +│ ├── namespace: Optional[str] +│ └── log_level: str +├── .clusters: ClusterHandler +│ ├── .create(name, ...) -> Cluster +│ ├── .get(name, ...) -> Cluster +│ ├── .list(...) -> List[RayCluster] +│ └── .list_queued(...) -> List[RayCluster] +└── .jobs: JobHandler + ├── .submit(name, entrypoint, ...) -> RayJob + └── .create(name, entrypoint, ...) -> RayJob +``` + +### Internal Wiring + +The `Codeflare.__init__()` method: + +1. Creates a K8s `ApiClient` via `kube_authkit.get_k8s_client(config=auth_config)` +2. Sets the module-level global via the existing `set_api_client()` function +3. Instantiates `ClusterHandler` and `JobHandler`, passing a reference to `self` + +Existing code paths (`Cluster`, `RayJob`, Kueue, etc.) call `get_api_client()` internally, which returns the global client. This means the new entrypoint "just works" without modifying internals. + +## Components + +### `SDKConfig` Dataclass + +```python +@dataclass +class SDKConfig: + auth: AuthConfig = field(default_factory=lambda: AuthConfig(method="auto")) + retries: int = 3 + timeout: int = 300 + namespace: Optional[str] = None + log_level: str = "WARNING" +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `auth` | `AuthConfig` | `AuthConfig(method="auto")` | kube-authkit authentication config | +| `retries` | `int` | `3` | Number of retries for K8s API calls | +| `timeout` | `int` | `300` | Default timeout in seconds for blocking operations | +| `namespace` | `Optional[str]` | `None` | Default namespace; auto-detected if not set | +| `log_level` | `str` | `"WARNING"` | Logging level for `codeflare_sdk` logger | + +### `Codeflare` Class + +```python +class Codeflare: + def __init__(self, config: Optional[SDKConfig] = None): + self.config = config or SDKConfig() + logging.getLogger("codeflare_sdk").setLevel(self.config.log_level) + self._client = get_k8s_client(config=self.config.auth) + set_api_client(self._client) + self.clusters = ClusterHandler(self) + self.jobs = JobHandler(self) +``` + +No-arg construction `Codeflare()` works via auto-detection (kubeconfig or in-cluster). + +### `ClusterHandler` + +```python +class ClusterHandler: + def __init__(self, sdk: "Codeflare"): + self._sdk = sdk + + def create(self, name: str, namespace: Optional[str] = None, **kwargs) -> Cluster: + """Create and return a new Cluster (does not apply it yet).""" + ns = namespace or self._sdk.config.namespace + cluster_config = ClusterConfiguration(name=name, namespace=ns, **kwargs) + return Cluster(cluster_config) + + def get(self, name: str, namespace: Optional[str] = None, **kwargs) -> Cluster: + """Retrieve an existing cluster by name.""" + ns = namespace or self._sdk.config.namespace or "default" + return get_cluster(cluster_name=name, namespace=ns, **kwargs) + + def list(self, namespace: Optional[str] = None) -> list: + """List all Ray clusters in a namespace.""" + ns = namespace or self._sdk.config.namespace or "default" + return list_all_clusters(ns, print_to_console=False) + + def list_queued(self, namespace: Optional[str] = None) -> list: + """List all queued Ray clusters in a namespace.""" + ns = namespace or self._sdk.config.namespace or "default" + return list_all_queued(ns, print_to_console=False) +``` + +### `JobHandler` + +```python +class JobHandler: + def __init__(self, sdk: "Codeflare"): + self._sdk = sdk + + def submit(self, name: str, entrypoint: str, namespace: Optional[str] = None, + **kwargs) -> RayJob: + """Create and immediately submit a RayJob.""" + ns = namespace or self._sdk.config.namespace + job = RayJob(job_name=name, entrypoint=entrypoint, namespace=ns, **kwargs) + job.submit() + return job + + def create(self, name: str, entrypoint: str, namespace: Optional[str] = None, + **kwargs) -> RayJob: + """Create a RayJob without submitting it (for inspection/modification).""" + ns = namespace or self._sdk.config.namespace + return RayJob(job_name=name, entrypoint=entrypoint, namespace=ns, **kwargs) +``` + +## Legacy Auth Removal + +The following classes and functions are **removed** (not deprecated — removed): + +| Removed | Replacement | +|---------|-------------| +| `TokenAuthentication` | `SDKConfig(auth=AuthConfig(token="..."))` | +| `KubeConfigFileAuthentication` | `SDKConfig(auth=AuthConfig(method="kubeconfig"))` | +| `Authentication` (abstract base) | No replacement needed | +| `KubeConfiguration` (abstract base) | No replacement needed | +| `set_api_client()` (public export) | `Codeflare(config=SDKConfig(auth=...))` | + +The `set_api_client()` function remains in `auth.py` as an internal function (used by `Codeflare.__init__`), but is no longer exported from `__init__.py`. + +The `config_check()` and `get_api_client()` functions remain unchanged — they are internal utilities used by `Cluster`, `RayJob`, and other code paths. + +## File Layout + +### New Files + +| File | Contents | +|------|----------| +| `src/codeflare_sdk/codeflare.py` | `Codeflare`, `SDKConfig`, `ClusterHandler`, `JobHandler` | + +### Modified Files + +| File | Changes | +|------|---------| +| `src/codeflare_sdk/__init__.py` | Export `Codeflare`, `SDKConfig`. Remove `Authentication`, `KubeConfiguration`, `TokenAuthentication`, `KubeConfigFileAuthentication`, `set_api_client` exports. | +| `src/codeflare_sdk/common/kubernetes_cluster/auth.py` | Remove `TokenAuthentication`, `KubeConfigFileAuthentication`, `Authentication`, `KubeConfiguration` classes. Keep `config_check()`, `get_api_client()`, `set_api_client()` as internal functions. | +| `src/codeflare_sdk/common/__init__.py` | Remove legacy auth class exports. | + +## Usage Examples + +### Minimal (auto-detect auth) + +```python +from codeflare_sdk import Codeflare + +cf = Codeflare() +clusters = cf.clusters.list(namespace="my-ns") +``` + +### Token auth with config + +```python +from codeflare_sdk import Codeflare, SDKConfig +from kube_authkit import AuthConfig + +cf = Codeflare(config=SDKConfig( + auth=AuthConfig( + k8s_api_host="https://api.my-cluster.com:6443", + token="sha256~abc123..." + ), + namespace="my-project", + retries=5, + timeout=600, + log_level="DEBUG" +)) + +# Create and apply a cluster +cluster = cf.clusters.create( + name="train-cluster", + num_workers=4, + worker_extended_resource_requests={"nvidia.com/gpu": 1} +) +cluster.apply() +cluster.wait_ready() + +# Submit a job to the cluster +job = cf.jobs.submit( + name="training-job", + entrypoint="python train.py", + cluster_name="train-cluster" +) +job.status() +``` + +### In a notebook + +```python +from codeflare_sdk import Codeflare, SDKConfig +from kube_authkit import AuthConfig + +cf = Codeflare(config=SDKConfig( + auth=AuthConfig(method="auto"), + namespace="data-science" +)) + +# List what's running +cf.clusters.list() + +# Get an existing cluster +cluster = cf.clusters.get("my-existing-cluster") +cluster.details() +``` + +## Testing Strategy + +- Unit tests for `SDKConfig` validation (bad log_level, negative retries, etc.) +- Unit tests for `Codeflare.__init__` with mocked kube-authkit +- Unit tests for `ClusterHandler` methods (mock `Cluster`, `get_cluster`, etc.) +- Unit tests for `JobHandler` methods (mock `RayJob`) +- Integration test: verify `Codeflare()` sets the global `api_client` correctly +- Test that removed classes are no longer importable from `codeflare_sdk` + +## Error Handling + +- `Codeflare.__init__` raises on auth failure (kube-authkit exceptions propagate) +- `SDKConfig.__post_init__` validates `retries >= 0`, `timeout > 0`, and `log_level` is valid +- Handler methods propagate existing exceptions from `Cluster`/`RayJob` unchanged diff --git a/src/codeflare_sdk/__init__.py b/src/codeflare_sdk/__init__.py index 7d9aa4549..f96024748 100644 --- a/src/codeflare_sdk/__init__.py +++ b/src/codeflare_sdk/__init__.py @@ -14,20 +14,7 @@ from .common.widgets import view_clusters -from .common import ( - Authentication, - KubeConfiguration, - TokenAuthentication, - KubeConfigFileAuthentication, -) - -from .common.kubernetes_cluster import set_api_client - -# Export kube-authkit at top level for convenience -try: - from kube_authkit import AuthConfig, get_k8s_client -except ImportError: - pass # Will show warning from auth.py +from .codeflare import Codeflare, SDKConfig from .common.kueue import ( list_local_queues, @@ -39,7 +26,7 @@ from importlib.metadata import version, PackageNotFoundError try: - __version__ = version("codeflare-sdk") # use metadata associated with built package + __version__ = version("codeflare-sdk") except PackageNotFoundError: __version__ = "v0.0.0" diff --git a/src/codeflare_sdk/codeflare.py b/src/codeflare_sdk/codeflare.py new file mode 100644 index 000000000..e9650a2e7 --- /dev/null +++ b/src/codeflare_sdk/codeflare.py @@ -0,0 +1,208 @@ +# Copyright 2026 IBM, Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Single entrypoint for the CodeFlare SDK. + +Usage: + from codeflare_sdk import Codeflare, SDKConfig + from kube_authkit import AuthConfig + + cf = Codeflare(config=SDKConfig( + auth=AuthConfig(method="auto"), + namespace="my-project", + )) + + cluster = cf.clusters.create(name="my-cluster", num_workers=4) + cluster.apply() +""" + +import logging +from dataclasses import dataclass, field +from typing import Optional + +from kube_authkit import AuthConfig, get_k8s_client +from .common.kubernetes_cluster.auth import set_api_client +from .ray.cluster.cluster import ( + Cluster, + get_cluster, + list_all_clusters, + list_all_queued, +) +from .ray.cluster.config import ClusterConfiguration +from .ray.rayjobs.rayjob import RayJob + +_VALID_LOG_LEVELS = ("CRITICAL", "DEBUG", "ERROR", "INFO", "WARNING") + + +@dataclass +class SDKConfig: + """Configuration for the CodeFlare SDK. + + Args: + auth: kube-authkit AuthConfig for Kubernetes authentication. + retries: Number of retries for K8s API calls. + timeout: Default timeout in seconds for blocking operations. + namespace: Default namespace for all operations. Auto-detected if not set. + log_level: Logging level for the codeflare_sdk logger. + """ + + auth: AuthConfig = field(default_factory=lambda: AuthConfig(method="auto")) + retries: int = 3 + timeout: int = 300 + namespace: Optional[str] = None + log_level: str = "WARNING" + + def __post_init__(self): + if self.retries < 0: + raise ValueError("retries must be >= 0") + if self.timeout <= 0: + raise ValueError("timeout must be > 0") + if self.log_level not in _VALID_LOG_LEVELS: + raise ValueError( + f"log_level must be one of {_VALID_LOG_LEVELS}, got '{self.log_level}'" + ) + + +class ClusterHandler: + """Namespace accessor for Ray cluster operations.""" + + def __init__(self, sdk: "Codeflare"): + self._sdk = sdk + + def create(self, name: str, namespace: Optional[str] = None, **kwargs) -> "Cluster": + """Create a new Cluster object (does not apply it to K8s yet). + + Args: + name: Cluster name. + namespace: K8s namespace. Falls back to SDKConfig.namespace or 'default'. + **kwargs: Forwarded to ClusterConfiguration. + + Returns: + Cluster instance ready for .apply(). + """ + ns = namespace or self._sdk.config.namespace or "default" + cluster_config = ClusterConfiguration(name=name, namespace=ns, **kwargs) + return Cluster(cluster_config) + + def get(self, name: str, namespace: Optional[str] = None, **kwargs) -> "Cluster": + """Retrieve an existing cluster by name. + + Args: + name: Cluster name. + namespace: K8s namespace. Falls back to SDKConfig.namespace or 'default'. + **kwargs: Forwarded to get_cluster. + + Returns: + Cluster instance. + """ + ns = namespace or self._sdk.config.namespace or "default" + return get_cluster(cluster_name=name, namespace=ns, **kwargs) + + def list(self, namespace: Optional[str] = None) -> list: + """List all Ray clusters in a namespace. + + Args: + namespace: K8s namespace. Falls back to SDKConfig.namespace or 'default'. + + Returns: + List of RayCluster objects. + """ + ns = namespace or self._sdk.config.namespace or "default" + return list_all_clusters(ns, print_to_console=False) + + def list_queued(self, namespace: Optional[str] = None) -> list: + """List all queued Ray clusters in a namespace. + + Args: + namespace: K8s namespace. Falls back to SDKConfig.namespace or 'default'. + + Returns: + List of queued RayCluster objects. + """ + ns = namespace or self._sdk.config.namespace or "default" + return list_all_queued(ns, print_to_console=False) + + +class JobHandler: + """Namespace accessor for RayJob operations.""" + + def __init__(self, sdk: "Codeflare"): + self._sdk = sdk + + def submit( + self, + name: str, + entrypoint: str, + namespace: Optional[str] = None, + **kwargs, + ) -> "RayJob": + """Create and immediately submit a RayJob. + + Args: + name: Job name. + entrypoint: Python script or command to run. + namespace: K8s namespace. Falls back to SDKConfig.namespace or 'default'. + **kwargs: Forwarded to RayJob constructor (cluster_name, cluster_config, etc.). + + Returns: + Submitted RayJob instance. + """ + ns = namespace or self._sdk.config.namespace or "default" + job = RayJob(job_name=name, entrypoint=entrypoint, namespace=ns, **kwargs) + job.submit() + return job + + def create( + self, + name: str, + entrypoint: str, + namespace: Optional[str] = None, + **kwargs, + ) -> "RayJob": + """Create a RayJob without submitting it. + + Args: + name: Job name. + entrypoint: Python script or command to run. + namespace: K8s namespace. Falls back to SDKConfig.namespace or 'default'. + **kwargs: Forwarded to RayJob constructor (cluster_name, cluster_config, etc.). + + Returns: + RayJob instance (not yet submitted). + """ + ns = namespace or self._sdk.config.namespace or "default" + return RayJob(job_name=name, entrypoint=entrypoint, namespace=ns, **kwargs) + + +class Codeflare: + """Single entrypoint for the CodeFlare SDK. + + Authenticates to Kubernetes via kube-authkit and provides + namespace-accessor handlers for clusters and jobs. + + Args: + config: SDK configuration. Defaults to auto-detection. + """ + + def __init__(self, config: Optional[SDKConfig] = None): + self.config = config or SDKConfig() + + logging.getLogger("codeflare_sdk").setLevel(self.config.log_level) + + self._client = get_k8s_client(config=self.config.auth) + set_api_client(self._client) + + self.clusters = ClusterHandler(self) + self.jobs = JobHandler(self) diff --git a/src/codeflare_sdk/common/__init__.py b/src/codeflare_sdk/common/__init__.py index a61de0727..53d8fd565 100644 --- a/src/codeflare_sdk/common/__init__.py +++ b/src/codeflare_sdk/common/__init__.py @@ -1,14 +1,2 @@ -# Importing everything from the kubernetes_cluster module -from .kubernetes_cluster import ( - Authentication, - KubeConfiguration, - TokenAuthentication, - KubeConfigFileAuthentication, - _kube_api_error_handling, -) - -# Re-export kube-authkit if available -try: - from .kubernetes_cluster import AuthConfig, get_k8s_client -except ImportError: - pass # kube-authkit not available +from .kubernetes_cluster import _kube_api_error_handling +from .kubernetes_cluster import AuthConfig, get_k8s_client diff --git a/src/codeflare_sdk/common/kubernetes_cluster/__init__.py b/src/codeflare_sdk/common/kubernetes_cluster/__init__.py index 9ed590a7a..d5610b4a6 100644 --- a/src/codeflare_sdk/common/kubernetes_cluster/__init__.py +++ b/src/codeflare_sdk/common/kubernetes_cluster/__init__.py @@ -1,40 +1,17 @@ from .auth import ( - Authentication, - KubeConfiguration, - TokenAuthentication, - KubeConfigFileAuthentication, config_check, get_api_client, set_api_client, ) -# Re-export kube-authkit for convenience -try: - from kube_authkit import AuthConfig, get_k8s_client +from kube_authkit import AuthConfig, get_k8s_client - __all__ = [ - # Legacy - "Authentication", - "KubeConfiguration", - "TokenAuthentication", - "KubeConfigFileAuthentication", - "config_check", - "get_api_client", - "set_api_client", - # New - kube-authkit - "AuthConfig", - "get_k8s_client", - ] -except ImportError: - __all__ = [ - # Legacy only - "Authentication", - "KubeConfiguration", - "TokenAuthentication", - "KubeConfigFileAuthentication", - "config_check", - "get_api_client", - "set_api_client", - ] +__all__ = [ + "config_check", + "get_api_client", + "set_api_client", + "AuthConfig", + "get_k8s_client", +] from .kube_api_helpers import _kube_api_error_handling diff --git a/src/codeflare_sdk/common/kubernetes_cluster/auth.py b/src/codeflare_sdk/common/kubernetes_cluster/auth.py index 0706055c0..0c433424a 100644 --- a/src/codeflare_sdk/common/kubernetes_cluster/auth.py +++ b/src/codeflare_sdk/common/kubernetes_cluster/auth.py @@ -13,20 +13,15 @@ # limitations under the License. """ -The auth sub-module contains authentication methods for Kubernetes clusters. +The auth sub-module contains Kubernetes authentication utilities. -Recommended: Use kube-authkit's AuthConfig directly for new code. -Legacy: TokenAuthentication and KubeConfigFileAuthentication are deprecated but still supported. +Authentication is handled exclusively via kube-authkit's AuthConfig. +Use the Codeflare class as the primary entrypoint. """ -import abc import os -import warnings from typing import Optional -import urllib3 - -# Import kube-authkit (mandatory dependency) from kube_authkit import AuthConfig, get_k8s_client from kubernetes import client, config @@ -39,182 +34,6 @@ WORKBENCH_CA_CERT_PATH = "/etc/pki/tls/custom-certs/ca-bundle.crt" -# Deprecation message template -_DEPRECATION_MSG = ( - "{cls_name} is deprecated and will be removed in a future version. " - "Please use kube-authkit's AuthConfig directly. " - "See: https://github.com/opendatahub-io/kube-authkit" -) - - -class Authentication(metaclass=abc.ABCMeta): - """ - An abstract class that defines the necessary methods for authenticating to a remote environment. - Specifically, this class defines the need for a `login()` and a `logout()` function. - """ - - def login(self): - """ - Method for logging in to a remote cluster. - """ - pass - - def logout(self): - """ - Method for logging out of the remote cluster. - """ - pass - - -class KubeConfiguration(metaclass=abc.ABCMeta): - """ - An abstract class that defines the method for loading a user defined config file using the `load_kube_config()` function - """ - - def load_kube_config(self): - """ - Method for setting your Kubernetes configuration to a certain file - """ - pass - - def logout(self): - """ - Method for logging out of the remote cluster - """ - pass - - -from typing_extensions import deprecated - - -@deprecated("Use kube_authkit.AuthConfig with token strategy instead.") -class TokenAuthentication(Authentication): - """ - DEPRECATED: Use kube_authkit.AuthConfig with token strategy instead. - - `TokenAuthentication` is a subclass of `Authentication`. It can be used to authenticate to a Kubernetes - cluster when the user has an API token and the API server address. - """ - - def __init__( - self, - token: str, - server: str, - skip_tls: bool = False, - ca_cert_path: str = None, - ): - """ - Initialize a TokenAuthentication object that requires a value for `token`, the API Token - and `server`, the API server address for authenticating to a Kubernetes cluster. - """ - warnings.warn( - _DEPRECATION_MSG.format(cls_name="TokenAuthentication"), - DeprecationWarning, - stacklevel=2, - ) - - self.token = token - self.server = server - self.skip_tls = skip_tls - self.ca_cert_path = _gen_ca_cert_path(ca_cert_path) - - def login(self) -> str: - """ - This function is used to log in to a Kubernetes cluster using the user's API token and API server address. - Depending on the cluster, a user can choose to login in with `--insecure-skip-tls-verify` by setting `skip_tls` - to `True` or `--certificate-authority` by setting `skip_tls` to False and providing a path to a ca bundle with `ca_cert_path`. - - Note: kube-authkit does not support direct token authentication via AuthConfig, - so this uses the legacy implementation. - """ - global config_path - global api_client - - # Legacy implementation (kube-authkit doesn't support direct token auth) - try: - configuration = client.Configuration() - configuration.api_key_prefix["authorization"] = "Bearer" - configuration.host = self.server - configuration.api_key["authorization"] = self.token - - if self.skip_tls: - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - print("Insecure request warnings have been disabled") - configuration.verify_ssl = False - - api_client = client.ApiClient(configuration) - if not self.skip_tls: - _client_with_cert(api_client, self.ca_cert_path) - - client.AuthenticationApi(api_client).get_api_group() - config_path = None - return "Logged into %s" % self.server - except client.ApiException as e: - _kube_api_error_handling(e) - - def logout(self) -> str: - """ - This function is used to logout of a Kubernetes cluster. - """ - global config_path - config_path = None - global api_client - api_client = None - return "Successfully logged out of %s" % self.server - - -class KubeConfigFileAuthentication(KubeConfiguration): - """ - DEPRECATED: Use kube_authkit.AuthConfig with kubeconfig strategy instead. - - A class that defines the necessary methods for passing a user's own Kubernetes config file. - Specifically this class defines the `load_kube_config()` and `config_check()` functions. - """ - - def __init__(self, kube_config_path: str = None): - warnings.warn( - _DEPRECATION_MSG.format(cls_name="KubeConfigFileAuthentication"), - DeprecationWarning, - stacklevel=2, - ) - self.kube_config_path = kube_config_path - - def load_kube_config(self): - """ - Function for loading a user's own predefined Kubernetes config file. - """ - global config_path - global api_client - - if self.kube_config_path == None: - return "Please specify a config file path" - - # Try kube-authkit first - try: - auth_config = AuthConfig(method="kubeconfig") - # kube-authkit auto-detects kubeconfig location, but we need to set it - # This may need adjustment based on actual kube-authkit API - api_client = get_k8s_client(config=auth_config) - config_path = self.kube_config_path - return "Loaded user config file at path %s" % self.kube_config_path - except Exception as e: - warnings.warn( - f"kube-authkit failed, using legacy method: {e}", RuntimeWarning - ) - # Reset for legacy implementation - api_client = None - - # Legacy implementation - try: - config_path = self.kube_config_path - api_client = None - config.load_kube_config(config_path) - response = "Loaded user config file at path %s" % self.kube_config_path - except config.ConfigException: # pragma: no cover - config_path = None - raise Exception("Please specify a config file path") - return response - def config_check() -> str: """ @@ -341,24 +160,12 @@ def set_api_client(new_client: client.ApiClient): """ Set a custom Kubernetes API client for the SDK to use. - This is useful when you want to use kube-authkit or other authentication - methods to create an API client and register it with the CodeFlare SDK. - - Example: - >>> from kube_authkit import get_k8s_client, AuthConfig - >>> from codeflare_sdk.common.kubernetes_cluster.auth import set_api_client - >>> - >>> auth_config = AuthConfig(k8s_api_host="...", token="...") - >>> api_client = get_k8s_client(config=auth_config) - >>> set_api_client(api_client) + This is an internal function called by Codeflare.__init__(). + Users should use the Codeflare class instead of calling this directly. Args: new_client: The Kubernetes API client instance to use. """ global api_client, config_path api_client = new_client - config_path = "custom" # Mark as configured with custom client - # verify the client works by making a simple API call - client.AuthenticationApi(api_client).get_api_group() - # print message confirming successful configuration - print("Custom API client has been set and verified successfully.") + config_path = "custom" diff --git a/src/codeflare_sdk/common/kubernetes_cluster/test_auth.py b/src/codeflare_sdk/common/kubernetes_cluster/test_auth.py index 559d248b1..4425e02e9 100644 --- a/src/codeflare_sdk/common/kubernetes_cluster/test_auth.py +++ b/src/codeflare_sdk/common/kubernetes_cluster/test_auth.py @@ -13,135 +13,33 @@ # limitations under the License. from codeflare_sdk.common.kubernetes_cluster import ( - Authentication, - KubeConfigFileAuthentication, - TokenAuthentication, config_check, set_api_client, ) from kubernetes import client, config import os -from pathlib import Path import pytest -parent = Path(__file__).resolve().parents[4] # project directory - @pytest.fixture(autouse=True) def reset_auth_globals(mocker): """Reset global auth state before and after each test to ensure test isolation.""" import codeflare_sdk.common.kubernetes_cluster.auth as auth_module - # Store original values original_api_client = auth_module.api_client original_config_path = auth_module.config_path - # Reset before test auth_module.api_client = None auth_module.config_path = None - # Mock kubernetes client to prevent actual API calls in all tests - # Individual tests can override these mocks as needed mocker.patch.object(client.ApiClient, "call_api", return_value=None) yield - # Reset after test auth_module.api_client = original_api_client auth_module.config_path = original_config_path -def test_token_auth_creation(): - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication(token="token", server="server") - assert token_auth.token == "token" - assert token_auth.server == "server" - assert token_auth.skip_tls is False - assert token_auth.ca_cert_path is None - - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication(token="token", server="server", skip_tls=True) - assert token_auth.token == "token" - assert token_auth.server == "server" - assert token_auth.skip_tls is True - assert token_auth.ca_cert_path is None - - os.environ["CF_SDK_CA_CERT_PATH"] = "/etc/pki/tls/custom-certs/ca-bundle.crt" - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication(token="token", server="server", skip_tls=False) - assert token_auth.token == "token" - assert token_auth.server == "server" - assert token_auth.skip_tls is False - assert token_auth.ca_cert_path == "/etc/pki/tls/custom-certs/ca-bundle.crt" - os.environ.pop("CF_SDK_CA_CERT_PATH") - - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication( - token="token", - server="server", - skip_tls=False, - ca_cert_path=f"{parent}/tests/auth-test.crt", - ) - assert token_auth.token == "token" - assert token_auth.server == "server" - assert token_auth.skip_tls is False - assert token_auth.ca_cert_path == f"{parent}/tests/auth-test.crt" - - -def test_token_auth_login_logout(mocker): - mocker.patch.object(client, "ApiClient") - - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication( - token="testtoken", - server="testserver:6443", - skip_tls=False, - ca_cert_path=None, - ) - assert token_auth.login() == ("Logged into testserver:6443") - assert token_auth.logout() == ("Successfully logged out of testserver:6443") - - -def test_token_auth_login_tls(mocker): - mocker.patch.object(client, "ApiClient") - - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication( - token="testtoken", - server="testserver:6443", - skip_tls=True, - ca_cert_path=None, - ) - assert token_auth.login() == ("Logged into testserver:6443") - - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication( - token="testtoken", - server="testserver:6443", - skip_tls=False, - ca_cert_path=None, - ) - assert token_auth.login() == ("Logged into testserver:6443") - - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication( - token="testtoken", - server="testserver:6443", - skip_tls=False, - ca_cert_path=f"{parent}/tests/auth-test.crt", - ) - assert token_auth.login() == ("Logged into testserver:6443") - - os.environ["CF_SDK_CA_CERT_PATH"] = f"{parent}/tests/auth-test.crt" - with pytest.warns(DeprecationWarning): - token_auth = TokenAuthentication( - token="testtoken", - server="testserver:6443", - skip_tls=False, - ) - assert token_auth.login() == ("Logged into testserver:6443") - - def test_config_check_no_config_file(mocker): mocker.patch("os.path.expanduser", return_value="/mock/home/directory") mocker.patch("os.path.isfile", return_value=False) @@ -184,80 +82,8 @@ def test_config_check_with_config_path_and_no_api_client(mocker): assert result == "/mock/config/path" -def test_load_kube_config(mocker): - mocker.patch.object(config, "load_kube_config") - - with pytest.warns(DeprecationWarning): - kube_config_auth = KubeConfigFileAuthentication( - kube_config_path="/path/to/your/config" - ) - response = kube_config_auth.load_kube_config() - - assert ( - response - == "Loaded user config file at path %s" % kube_config_auth.kube_config_path - ) - - with pytest.warns(DeprecationWarning): - kube_config_auth = KubeConfigFileAuthentication(kube_config_path=None) - response = kube_config_auth.load_kube_config() - assert response == "Please specify a config file path" - - -def test_auth_coverage(): - abstract = Authentication() - abstract.login() - abstract.logout() - - -def test_deprecation_warnings(): - """Test that deprecation warnings are shown for legacy classes.""" - with pytest.warns(DeprecationWarning, match="TokenAuthentication is deprecated"): - TokenAuthentication(token="test", server="https://test:6443") - - with pytest.warns( - DeprecationWarning, match="KubeConfigFileAuthentication is deprecated" - ): - KubeConfigFileAuthentication(kube_config_path="/path/to/config") - - -def test_token_auth_uses_legacy_implementation(mocker): - """Test TokenAuthentication uses legacy implementation (kube-authkit doesn't support direct token auth).""" - mocker.patch.object(client, "ApiClient") - - # Suppress deprecation warnings in this test - with pytest.warns(DeprecationWarning): - auth = TokenAuthentication(token="test", server="https://test:6443") - - result = auth.login() - - # TokenAuthentication always uses legacy implementation - assert result == "Logged into https://test:6443" - - -def test_kubeconfig_auth_with_kube_authkit(mocker): - """Test KubeConfigFileAuthentication uses kube-authkit.""" - # Mock kube-authkit (always available as mandatory dependency) - mock_get_k8s_client = mocker.patch( - "codeflare_sdk.common.kubernetes_cluster.auth.get_k8s_client" - ) - mock_client = mocker.MagicMock() - mock_get_k8s_client.return_value = mock_client - - # Suppress deprecation warnings - with pytest.warns(DeprecationWarning): - auth = KubeConfigFileAuthentication(kube_config_path="/path/to/config") - - result = auth.load_kube_config() - - # Verify kube-authkit was called - assert mock_get_k8s_client.called - assert result == "Loaded user config file at path /path/to/config" - - def test_config_check_with_kube_authkit(mocker): """Test config_check uses kube-authkit auto-detection.""" - # Mock kube-authkit (always available as mandatory dependency) mock_auth_config = mocker.patch( "codeflare_sdk.common.kubernetes_cluster.auth.AuthConfig" ) @@ -267,18 +93,13 @@ def test_config_check_with_kube_authkit(mocker): mock_client = mocker.MagicMock() mock_get_k8s_client.return_value = mock_client - # Mock AuthenticationApi to prevent actual API calls mocker.patch.object(client, "AuthenticationApi") - - # Reset global state mocker.patch("codeflare_sdk.common.kubernetes_cluster.auth.api_client", None) mocker.patch("codeflare_sdk.common.kubernetes_cluster.auth.config_path", None) config_check() - # Should call AuthConfig with method="auto" mock_auth_config.assert_called_once_with(method="auto") - # Should call get_k8s_client assert mock_get_k8s_client.called @@ -286,12 +107,8 @@ def test_set_api_client(): """Test set_api_client registers a custom API client.""" import codeflare_sdk.common.kubernetes_cluster.auth as auth_module - # Create a mock client mock_client = client.ApiClient() - - # Set it using the new function set_api_client(mock_client) - # Verify it was registered assert auth_module.api_client is mock_client assert auth_module.config_path == "custom" diff --git a/src/codeflare_sdk/common/kubernetes_cluster/test_auth_edge_cases.py b/src/codeflare_sdk/common/kubernetes_cluster/test_auth_edge_cases.py index 39d29b801..df43367dc 100644 --- a/src/codeflare_sdk/common/kubernetes_cluster/test_auth_edge_cases.py +++ b/src/codeflare_sdk/common/kubernetes_cluster/test_auth_edge_cases.py @@ -13,43 +13,14 @@ # limitations under the License. """ -Tests for edge cases and error paths in auth.py to improve coverage. +Tests for edge cases in auth.py cert helpers. """ import pytest -from kubernetes import client from codeflare_sdk.common.kubernetes_cluster.auth import ( - TokenAuthentication, - KubeConfigFileAuthentication, _client_with_cert, _gen_ca_cert_path, ) -import os -from pathlib import Path - - -def test_kubeconfig_auth_fallback_to_legacy(mocker): - """Test that KubeConfigFileAuthentication falls back to legacy when kube-authkit fails.""" - # Make get_k8s_client raise an exception to trigger fallback - mocker.patch( - "codeflare_sdk.common.kubernetes_cluster.auth.get_k8s_client", - side_effect=Exception("kube-authkit failed"), - ) - - # Mock the legacy config.load_kube_config - mock_load_config = mocker.patch("kubernetes.config.load_kube_config") - - # Create and use KubeConfigFileAuthentication - with pytest.warns(DeprecationWarning): - auth = KubeConfigFileAuthentication(kube_config_path="/fake/path") - - # This should fall back to legacy method - with mocker.patch("warnings.warn"): # Suppress the runtime warning - result = auth.load_kube_config() - - # Verify legacy method was called - assert mock_load_config.called - assert "Loaded user config file" in result def test_gen_ca_cert_path_with_env_var(monkeypatch): @@ -68,28 +39,23 @@ def test_gen_ca_cert_path_with_explicit_path(): def test_gen_ca_cert_path_defaults_to_none(monkeypatch): """Test _gen_ca_cert_path returns None when no cert path is configured.""" - # Remove environment variable if it exists monkeypatch.delenv("CF_SDK_CA_CERT_PATH", raising=False) result = _gen_ca_cert_path(None) - # Will be None unless WORKBENCH_CA_CERT_PATH exists assert result is None or result == "/etc/pki/tls/custom-certs/ca-bundle.crt" def test_client_with_cert_none(mocker, monkeypatch): """Test _client_with_cert when cert_path is None preserves existing ssl_ca_cert.""" - # Clear env var to ensure we're testing the None case monkeypatch.delenv("CF_SDK_CA_CERT_PATH", raising=False) mock_client = mocker.MagicMock() mock_client.configuration = mocker.MagicMock() - # Set an existing ssl_ca_cert to verify it's preserved mock_client.configuration.ssl_ca_cert = "/existing/ca.crt" _client_with_cert(mock_client, None) assert mock_client.configuration.verify_ssl is True - # ssl_ca_cert should be preserved (not overwritten to None) assert mock_client.configuration.ssl_ca_cert == "/existing/ca.crt" @@ -104,7 +70,6 @@ def test_client_with_cert_file_not_found(mocker): def test_client_with_cert_valid_file(mocker, tmp_path): """Test _client_with_cert sets ssl_ca_cert when valid cert file exists.""" - # Create a temporary cert file cert_file = tmp_path / "ca.crt" cert_file.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") @@ -116,31 +81,3 @@ def test_client_with_cert_valid_file(mocker, tmp_path): assert mock_client.configuration.verify_ssl is True assert mock_client.configuration.ssl_ca_cert == str(cert_file) - - -def test_token_auth_exception_handling(mocker): - """Test TokenAuthentication.login() handles ApiException.""" - from kubernetes.client import ApiException - - # Create real instances but mock the method that raises exception - mock_auth_instance = mocker.MagicMock() - mock_auth_instance.get_api_group.side_effect = ApiException( - status=401, reason="Unauthorized" - ) - - # Patch only AuthenticationApi's __init__ to return our mock - mocker.patch("kubernetes.client.AuthenticationApi", return_value=mock_auth_instance) - - # Mock error handling - mock_error_handler = mocker.patch( - "codeflare_sdk.common.kubernetes_cluster.auth._kube_api_error_handling" - ) - - with pytest.warns(DeprecationWarning): - auth = TokenAuthentication(token="test", server="https://test:6443") - - # login() should call error handler when ApiException occurs - auth.login() - - # Verify error handler was called - assert mock_error_handler.called diff --git a/src/codeflare_sdk/test_codeflare.py b/src/codeflare_sdk/test_codeflare.py new file mode 100644 index 000000000..43a06cdff --- /dev/null +++ b/src/codeflare_sdk/test_codeflare.py @@ -0,0 +1,391 @@ +# Copyright 2026 IBM, Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the Codeflare single entrypoint.""" + +import logging +import pytest +from unittest.mock import MagicMock +from kube_authkit import AuthConfig + + +class TestSDKConfig: + def test_default_config(self): + from codeflare_sdk.codeflare import SDKConfig + + config = SDKConfig() + assert config.retries == 3 + assert config.timeout == 300 + assert config.namespace is None + assert config.log_level == "WARNING" + assert isinstance(config.auth, AuthConfig) + + def test_custom_config(self): + from codeflare_sdk.codeflare import SDKConfig + + auth = AuthConfig(method="kubeconfig") + config = SDKConfig( + auth=auth, + retries=5, + timeout=600, + namespace="my-ns", + log_level="DEBUG", + ) + assert config.retries == 5 + assert config.timeout == 600 + assert config.namespace == "my-ns" + assert config.log_level == "DEBUG" + assert config.auth is auth + + def test_negative_retries_raises(self): + from codeflare_sdk.codeflare import SDKConfig + + with pytest.raises(ValueError, match="retries"): + SDKConfig(retries=-1) + + def test_zero_timeout_raises(self): + from codeflare_sdk.codeflare import SDKConfig + + with pytest.raises(ValueError, match="timeout"): + SDKConfig(timeout=0) + + def test_negative_timeout_raises(self): + from codeflare_sdk.codeflare import SDKConfig + + with pytest.raises(ValueError, match="timeout"): + SDKConfig(timeout=-10) + + def test_invalid_log_level_raises(self): + from codeflare_sdk.codeflare import SDKConfig + + with pytest.raises(ValueError, match="log_level"): + SDKConfig(log_level="INVALID") + + def test_zero_retries_allowed(self): + from codeflare_sdk.codeflare import SDKConfig + + config = SDKConfig(retries=0) + assert config.retries == 0 + + +class TestCodeflare: + def test_default_init(self, mocker): + """Codeflare() with no args uses auto-detection.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mock_get_k8s_client = mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mock_client = MagicMock() + mock_get_k8s_client.return_value = mock_client + + mock_set_api = mocker.patch("codeflare_sdk.codeflare.set_api_client") + + cf = Codeflare() + + assert isinstance(cf.config, SDKConfig) + mock_get_k8s_client.assert_called_once_with(config=cf.config.auth) + mock_set_api.assert_called_once_with(mock_client) + assert cf._client is mock_client + + def test_custom_config_init(self, mocker): + """Codeflare with explicit SDKConfig.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mock_get_k8s_client = mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mock_client = MagicMock() + mock_get_k8s_client.return_value = mock_client + mocker.patch("codeflare_sdk.codeflare.set_api_client") + + auth = AuthConfig(method="kubeconfig") + config = SDKConfig(auth=auth, namespace="test-ns", log_level="DEBUG") + cf = Codeflare(config=config) + + assert cf.config.namespace == "test-ns" + assert cf.config.log_level == "DEBUG" + mock_get_k8s_client.assert_called_once_with(config=auth) + + def test_sets_log_level(self, mocker): + """Codeflare sets the SDK logger level.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + + config = SDKConfig(log_level="DEBUG") + Codeflare(config=config) + + logger = logging.getLogger("codeflare_sdk") + assert logger.level == logging.DEBUG + + def test_has_cluster_handler(self, mocker): + """Codeflare exposes a clusters handler.""" + from codeflare_sdk.codeflare import Codeflare, ClusterHandler + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + + cf = Codeflare() + assert isinstance(cf.clusters, ClusterHandler) + + def test_has_job_handler(self, mocker): + """Codeflare exposes a jobs handler.""" + from codeflare_sdk.codeflare import Codeflare, JobHandler + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + + cf = Codeflare() + assert isinstance(cf.jobs, JobHandler) + + def test_auth_failure_propagates(self, mocker): + """Auth failure in kube-authkit propagates to caller.""" + from codeflare_sdk.codeflare import Codeflare + from kube_authkit.exceptions import AuthenticationError + + mocker.patch( + "codeflare_sdk.codeflare.get_k8s_client", + side_effect=AuthenticationError("bad token"), + ) + + with pytest.raises(AuthenticationError, match="bad token"): + Codeflare() + + +class TestClusterHandler: + @pytest.fixture + def cf(self, mocker): + """Create a Codeflare instance with mocked auth.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + return Codeflare(config=SDKConfig(namespace="default-ns")) + + def test_create_cluster(self, cf, mocker): + """create() returns a Cluster with the right config.""" + mock_cluster_cls = mocker.patch("codeflare_sdk.codeflare.Cluster") + mock_cluster_config_cls = mocker.patch( + "codeflare_sdk.codeflare.ClusterConfiguration" + ) + + result = cf.clusters.create(name="my-cluster", num_workers=3) + + mock_cluster_config_cls.assert_called_once_with( + name="my-cluster", namespace="default-ns", num_workers=3 + ) + mock_cluster_cls.assert_called_once_with(mock_cluster_config_cls.return_value) + assert result is mock_cluster_cls.return_value + + def test_create_cluster_override_namespace(self, cf, mocker): + """create() allows namespace override.""" + mocker.patch("codeflare_sdk.codeflare.Cluster") + mock_cluster_config_cls = mocker.patch( + "codeflare_sdk.codeflare.ClusterConfiguration" + ) + + cf.clusters.create(name="my-cluster", namespace="other-ns") + + mock_cluster_config_cls.assert_called_once_with( + name="my-cluster", namespace="other-ns" + ) + + def test_get_cluster(self, cf, mocker): + """get() delegates to get_cluster function.""" + mock_get = mocker.patch("codeflare_sdk.codeflare.get_cluster") + + result = cf.clusters.get(name="existing-cluster") + + mock_get.assert_called_once_with( + cluster_name="existing-cluster", namespace="default-ns" + ) + assert result is mock_get.return_value + + def test_get_cluster_override_namespace(self, cf, mocker): + """get() allows namespace override.""" + mock_get = mocker.patch("codeflare_sdk.codeflare.get_cluster") + + cf.clusters.get(name="existing-cluster", namespace="other-ns") + + mock_get.assert_called_once_with( + cluster_name="existing-cluster", namespace="other-ns" + ) + + def test_list_clusters(self, cf, mocker): + """list() delegates to list_all_clusters.""" + mock_list = mocker.patch("codeflare_sdk.codeflare.list_all_clusters") + mock_list.return_value = ["cluster1", "cluster2"] + + result = cf.clusters.list() + + mock_list.assert_called_once_with("default-ns", print_to_console=False) + assert result == ["cluster1", "cluster2"] + + def test_list_queued(self, cf, mocker): + """list_queued() delegates to list_all_queued.""" + mock_list = mocker.patch("codeflare_sdk.codeflare.list_all_queued") + mock_list.return_value = [] + + result = cf.clusters.list_queued() + + mock_list.assert_called_once_with("default-ns", print_to_console=False) + assert result == [] + + def test_list_no_default_namespace_uses_default(self, mocker): + """list() falls back to 'default' when no namespace configured.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + mock_list = mocker.patch("codeflare_sdk.codeflare.list_all_clusters") + + cf = Codeflare(config=SDKConfig(namespace=None)) + cf.clusters.list() + + mock_list.assert_called_once_with("default", print_to_console=False) + + def test_create_no_default_namespace_uses_default(self, mocker): + """create() falls back to 'default' when no namespace configured.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + mocker.patch("codeflare_sdk.codeflare.Cluster") + mock_config = mocker.patch("codeflare_sdk.codeflare.ClusterConfiguration") + + cf = Codeflare(config=SDKConfig(namespace=None)) + cf.clusters.create(name="test") + + mock_config.assert_called_once_with(name="test", namespace="default") + + +class TestJobHandler: + @pytest.fixture + def cf(self, mocker): + """Create a Codeflare instance with mocked auth.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + return Codeflare(config=SDKConfig(namespace="default-ns")) + + def test_submit_job(self, cf, mocker): + """submit() creates and submits a RayJob.""" + mock_rayjob_cls = mocker.patch("codeflare_sdk.codeflare.RayJob") + mock_job = MagicMock() + mock_rayjob_cls.return_value = mock_job + + result = cf.jobs.submit( + name="train", + entrypoint="python train.py", + cluster_name="my-cluster", + ) + + mock_rayjob_cls.assert_called_once_with( + job_name="train", + entrypoint="python train.py", + namespace="default-ns", + cluster_name="my-cluster", + ) + mock_job.submit.assert_called_once() + assert result is mock_job + + def test_submit_job_override_namespace(self, cf, mocker): + """submit() allows namespace override.""" + mock_rayjob_cls = mocker.patch("codeflare_sdk.codeflare.RayJob") + mock_rayjob_cls.return_value = MagicMock() + + cf.jobs.submit( + name="train", + entrypoint="python train.py", + namespace="other-ns", + cluster_name="my-cluster", + ) + + mock_rayjob_cls.assert_called_once_with( + job_name="train", + entrypoint="python train.py", + namespace="other-ns", + cluster_name="my-cluster", + ) + + def test_create_job_without_submit(self, cf, mocker): + """create() returns a RayJob without submitting.""" + mock_rayjob_cls = mocker.patch("codeflare_sdk.codeflare.RayJob") + mock_job = MagicMock() + mock_rayjob_cls.return_value = mock_job + + result = cf.jobs.create( + name="train", + entrypoint="python train.py", + cluster_name="my-cluster", + ) + + mock_rayjob_cls.assert_called_once_with( + job_name="train", + entrypoint="python train.py", + namespace="default-ns", + cluster_name="my-cluster", + ) + mock_job.submit.assert_not_called() + assert result is mock_job + + def test_submit_no_default_namespace_uses_default(self, mocker): + """submit() falls back to 'default' when no namespace configured.""" + from codeflare_sdk.codeflare import Codeflare, SDKConfig + + mocker.patch("codeflare_sdk.codeflare.get_k8s_client") + mocker.patch("codeflare_sdk.codeflare.set_api_client") + mock_rayjob_cls = mocker.patch("codeflare_sdk.codeflare.RayJob") + mock_rayjob_cls.return_value = MagicMock() + + cf = Codeflare(config=SDKConfig(namespace=None)) + cf.jobs.submit(name="job", entrypoint="python run.py") + + mock_rayjob_cls.assert_called_once_with( + job_name="job", entrypoint="python run.py", namespace="default" + ) + + +class TestLegacyAuthRemoved: + def test_token_auth_not_importable(self): + """TokenAuthentication is no longer exported from codeflare_sdk.""" + with pytest.raises(ImportError): + from codeflare_sdk import TokenAuthentication # noqa: F401 + + def test_kubeconfig_auth_not_importable(self): + """KubeConfigFileAuthentication is no longer exported from codeflare_sdk.""" + with pytest.raises(ImportError): + from codeflare_sdk import KubeConfigFileAuthentication # noqa: F401 + + def test_authentication_not_importable(self): + """Authentication base class is no longer exported from codeflare_sdk.""" + with pytest.raises(ImportError): + from codeflare_sdk import Authentication # noqa: F401 + + def test_kube_configuration_not_importable(self): + """KubeConfiguration base class is no longer exported from codeflare_sdk.""" + with pytest.raises(ImportError): + from codeflare_sdk import KubeConfiguration # noqa: F401 + + def test_set_api_client_not_importable(self): + """set_api_client is no longer exported from codeflare_sdk top-level.""" + with pytest.raises(ImportError): + from codeflare_sdk import set_api_client # noqa: F401 + + def test_codeflare_importable(self): + """Codeflare is importable from codeflare_sdk.""" + from codeflare_sdk import Codeflare # noqa: F401 + + def test_sdk_config_importable(self): + """SDKConfig is importable from codeflare_sdk.""" + from codeflare_sdk import SDKConfig # noqa: F401 diff --git a/tests/e2e/support.py b/tests/e2e/support.py index ae04ecadc..903f1de1b 100644 --- a/tests/e2e/support.py +++ b/tests/e2e/support.py @@ -16,7 +16,7 @@ # Authentication imports - prioritize kube-authkit try: from kube_authkit import get_k8s_client, AuthConfig - from codeflare_sdk import set_api_client + from codeflare_sdk.common.kubernetes_cluster.auth import set_api_client KUBE_AUTHKIT_AVAILABLE = True except ImportError: @@ -25,14 +25,9 @@ AuthConfig = None set_api_client = None -# Fallback to legacy authentication if kube-authkit is not available -try: - from codeflare_sdk import TokenAuthentication - - LEGACY_AUTH_AVAILABLE = True -except ImportError: - LEGACY_AUTH_AVAILABLE = False - TokenAuthentication = None +# Legacy authentication has been removed — kube-authkit is the only auth method +LEGACY_AUTH_AVAILABLE = False +TokenAuthentication = None def get_ray_cluster(cluster_name, namespace): diff --git a/tests/e2e_v2/job_submission/rayjob_cr/test_existing_cluster.py b/tests/e2e_v2/job_submission/rayjob_cr/test_existing_cluster.py index 8580342c2..a6fb52f0a 100644 --- a/tests/e2e_v2/job_submission/rayjob_cr/test_existing_cluster.py +++ b/tests/e2e_v2/job_submission/rayjob_cr/test_existing_cluster.py @@ -6,8 +6,8 @@ import pytest -from codeflare_sdk import Cluster, ClusterConfiguration, RayJob -from codeflare_sdk.common.kubernetes_cluster.auth import TokenAuthentication +from codeflare_sdk import Cluster, ClusterConfiguration, RayJob, Codeflare, SDKConfig +from kube_authkit import AuthConfig from ...utils.support import ( create_kueue_resources, @@ -36,12 +36,15 @@ def teardown_method(self): @pytest.mark.openshift @pytest.mark.parametrize("num_gpus", [CPU_CONFIG, GPU_CONFIG]) def test_openshift_remote_submission(self, num_gpus, require_gpu_flag): - auth = TokenAuthentication( - token=run_oc_command(["whoami", "--show-token=true"]), - server=run_oc_command(["whoami", "--show-server=true"]), - skip_tls=True, + Codeflare( + config=SDKConfig( + auth=AuthConfig( + k8s_api_host=run_oc_command(["whoami", "--show-server=true"]), + token=run_oc_command(["whoami", "--show-token=true"]), + skip_tls_verify=True, + ), + ) ) - auth.login() self.run_remote_submission(num_gpus=num_gpus) @@ -53,12 +56,15 @@ def test_kind_remote_submission(self, num_gpus, require_gpu_flag): @pytest.mark.openshift @pytest.mark.parametrize("num_gpus", [CPU_CONFIG, GPU_CONFIG]) def test_openshift_in_cluster_submission(self, num_gpus, require_gpu_flag): - auth = TokenAuthentication( - token=run_oc_command(["whoami", "--show-token=true"]), - server=run_oc_command(["whoami", "--show-server=true"]), - skip_tls=True, + Codeflare( + config=SDKConfig( + auth=AuthConfig( + k8s_api_host=run_oc_command(["whoami", "--show-server=true"]), + token=run_oc_command(["whoami", "--show-token=true"]), + skip_tls_verify=True, + ), + ) ) - auth.login() self.run_in_cluster_submission(num_gpus=num_gpus) diff --git a/tests/e2e_v2/upgrade/01_raycluster_sdk_upgrade_test.py b/tests/e2e_v2/upgrade/01_raycluster_sdk_upgrade_test.py index 78936ffac..0173e2254 100644 --- a/tests/e2e_v2/upgrade/01_raycluster_sdk_upgrade_test.py +++ b/tests/e2e_v2/upgrade/01_raycluster_sdk_upgrade_test.py @@ -2,7 +2,8 @@ import requests from time import sleep -from codeflare_sdk import Cluster, ClusterConfiguration, TokenAuthentication +from codeflare_sdk import Cluster, ClusterConfiguration, Codeflare, SDKConfig +from kube_authkit import AuthConfig from codeflare_sdk.ray.client import RayJobClient from tests.e2e.support import * @@ -60,12 +61,15 @@ def test_mnist_ray_cluster_sdk_auth(self): def run_mnist_raycluster_sdk_oauth(self): ray_image = get_ray_image() - auth = TokenAuthentication( - token=run_oc_command(["whoami", "--show-token=true"]), - server=run_oc_command(["whoami", "--show-server=true"]), - skip_tls=True, + Codeflare( + config=SDKConfig( + auth=AuthConfig( + k8s_api_host=run_oc_command(["whoami", "--show-server=true"]), + token=run_oc_command(["whoami", "--show-token=true"]), + skip_tls_verify=True, + ), + ) ) - auth.login() cluster = Cluster( ClusterConfiguration( @@ -108,12 +112,15 @@ def run_mnist_raycluster_sdk_oauth(self): class TestMnistJobSubmit: def setup_method(self): initialize_kubernetes_client(self) - auth = TokenAuthentication( - token=run_oc_command(["whoami", "--show-token=true"]), - server=run_oc_command(["whoami", "--show-server=true"]), - skip_tls=True, + Codeflare( + config=SDKConfig( + auth=AuthConfig( + k8s_api_host=run_oc_command(["whoami", "--show-server=true"]), + token=run_oc_command(["whoami", "--show-token=true"]), + skip_tls_verify=True, + ), + ) ) - auth.login() self.namespace = namespace self.cluster = get_cluster("mnist", self.namespace) if not self.cluster: