Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apis/gkeclusters/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,12 @@ spec:
description: >-
The type of credential this secret contains.
Kubeconfig contains a kubeconfig file with the cluster
endpoint and CA certificate. GCPServiceAccountKey
contains a GCP service account JSON key that can
authenticate to the cluster via GKE IAM.
endpoint and CA certificate. GoogleApplicationCredentials
contains a GCP service account JSON key that
authenticates to the cluster via GKE IAM.
enum:
- Kubeconfig
- GCPServiceAccountKey
- GoogleApplicationCredentials
name:
type: string
description: Name of the Secret.
Expand Down
14 changes: 14 additions & 0 deletions apis/inferenceclusters/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ spec:
description: >-
Optional reference to a Secret containing cloud
provider credentials for IAM-based authentication.
The type selects which cloud identity the
ProviderConfigs authenticate as, and must match the
cloud the existing cluster runs on.
required: [name]
properties:
name:
Expand All @@ -88,6 +91,17 @@ spec:
default: private_key
minLength: 1
maxLength: 253
type:
type: string
description: >-
Cloud identity type the credential authenticates
as. Must match the cloud the existing cluster runs
on. Defaults to GoogleApplicationCredentials.
default: GoogleApplicationCredentials
enum:
- GoogleApplicationCredentials
- AWSWebIdentityCredentials
- NebiusServiceAccountCredentials
cache:
type: object
description: >-
Expand Down
15 changes: 9 additions & 6 deletions apis/servingstacks/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,8 @@ spec:
Secrets used to authenticate to the target cluster. Typically
sourced from a GKECluster's status.secrets. All secrets must
be in the same namespace as this ServingStack. A Kubeconfig
secret is required. If a cloud-specific credential secret is
present (e.g. GCPServiceAccountKey), the ProviderConfigs
will use it for identity-based authentication instead of
secret is required. If a cloud identity secret is present, the
serving stack authenticates as that identity instead of
relying on the kubeconfig's embedded credentials.
minItems: 1
maxItems: 8
Expand All @@ -63,11 +62,15 @@ spec:
type: string
description: >-
The type of credential this secret contains. Kubeconfig
is required. Cloud-specific types are optional and
determine how the ProviderConfigs authenticate.
is required. Any other value is a cloud identity type;
when present, the serving stack authenticates to the
cluster as that identity instead of using the
kubeconfig's embedded credentials.
enum:
- Kubeconfig
- GCPServiceAccountKey
- GoogleApplicationCredentials
- AWSWebIdentityCredentials
- NebiusServiceAccountCredentials
name:
type: string
description: Name of the Secret.
Expand Down
10 changes: 6 additions & 4 deletions crossplane-project.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,13 @@ spec:
xpkg:
apiVersion: pkg.crossplane.io/v1
kind: Provider
package: xpkg.upbound.io/upbound/provider-helm
version: v1.2.0
# ToDo(haarchri): switch to official provider
package: xpkg.upbound.io/modelplane/provider-helm
version: v1.3.0-9a6fb4b
- type: xpkg
xpkg:
apiVersion: pkg.crossplane.io/v1
kind: Provider
package: xpkg.upbound.io/upbound/provider-kubernetes
version: v1.2.1
# ToDo(haarchri): switch to official provider
package: xpkg.upbound.io/modelplane/provider-kubernetes
version: v1.2.1-070dae7
10 changes: 7 additions & 3 deletions docs/manifests/concepts/inference-cluster-existing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ spec:
name: byo-cluster-kubeconfig
key: kubeconfig

# Optional: a cloud identity Secret for pulling images from private
# registries or accessing cloud APIs from the remote cluster.
# Optional: a cloud identity Secret so Modelplane authenticates to the
# cluster via the cloud's IAM instead of a token baked into the
# kubeconfig. type selects the cloud identity and must match the cluster's
# cloud; it defaults to GoogleApplicationCredentials. For example, a
# Nebius managed cluster:
# identitySecretRef:
# name: byo-cluster-sa-key
# key: private_key
# key: credentials.json
# type: NebiusServiceAccountCredentials

# Each pool's nodes must be labeled modelplane.ai/pool=<name> (here
# modelplane.ai/pool=gpu-h100). The scheduler pins a worker to its pool by
Expand Down
2 changes: 1 addition & 1 deletion docs/manifests/reference/servingstacks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ spec:
- type: Kubeconfig
name: west-gke-kubeconfig
key: kubeconfig
- type: GCPServiceAccountKey
- type: GoogleApplicationCredentials
name: west-gke-sa-key
key: private_key
versions:
Expand Down
6 changes: 3 additions & 3 deletions functions/compose-gke-cluster/function/fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@
_LABEL_POOL = "modelplane.ai/pool"

# Secret types written to XR status. compose-inference-cluster reads
# these to wire the kubeconfig and SA key into ProviderConfigs.
# these to wire the kubeconfig and SA key into ProviderConfigs. The SA key's
# type is the provider identity it authenticates as (see _IDENTITY_TYPE_GCP).
_SECRET_TYPE_KUBECONFIG = "Kubeconfig"
_SECRET_TYPE_GCP_SA_KEY = "GCPServiceAccountKey"

# Secret keys within the Kubernetes Secrets created by GCP providers.
_SECRET_KEY_KUBECONFIG = "kubeconfig"
Expand Down Expand Up @@ -503,7 +503,7 @@ def write_status(self) -> None:
key=_SECRET_KEY_KUBECONFIG,
),
v1alpha1.Secret(
type=_SECRET_TYPE_GCP_SA_KEY,
type=_IDENTITY_TYPE_GCP,
name=_sa_key_secret_name(self.xr),
key=_SECRET_KEY_GCP_SA,
),
Expand Down
4 changes: 2 additions & 2 deletions functions/compose-gke-cluster/tests/test_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async def test_compose(self) -> None:
"key": "kubeconfig",
},
{
"type": "GCPServiceAccountKey",
"type": "GoogleApplicationCredentials",
"name": "test-cluster-sa-key-3295c",
"key": "private_key",
},
Expand Down Expand Up @@ -446,7 +446,7 @@ async def test_compose(self) -> None:
"key": "kubeconfig",
},
{
"type": "GCPServiceAccountKey",
"type": "GoogleApplicationCredentials",
"name": "test-cluster-sa-key-3295c",
"key": "private_key",
},
Expand Down
43 changes: 30 additions & 13 deletions functions/compose-inference-cluster/function/fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@
# function's _LABEL_CLUSTER.
_LABEL_CLUSTER = "modelplane.ai/cluster"

# Secret types that couple compose-gke-cluster (writer) to this function
# (reader) and compose-serving-stack (reader).
# Secret type that couples compose-gke-cluster (writer) to this function
# (reader). Every other secret type is a provider identity type, passed through
# to the ProviderConfigs unchanged.
_SECRET_TYPE_KUBECONFIG = "Kubeconfig"
_SECRET_TYPE_GCP_SA_KEY = "GCPServiceAccountKey"

# The modelplane-system namespace. Used for the ServingStack XR,
# ClusterProviderConfig secretRefs, and status.namespace.
Expand Down Expand Up @@ -255,11 +255,16 @@ def compose_gke(self, gke: v1alpha1.Gke | None) -> None:

gke_ready = resource.get_condition(self.req.observed.resources.get("gke-cluster"), "Ready").status == "True"
kubeconfig_secret = self.observed_gke_secret(_SECRET_TYPE_KUBECONFIG)
sa_key = self.observed_gke_secret(_SECRET_TYPE_GCP_SA_KEY)
sa_key = self.observed_gke_secret(_IDENTITY_TYPE_GCP)
backend_exists = BACKEND_RESOURCE_KEY in self.req.observed.resources

if gke_ready and kubeconfig_secret:
self.compose_cluster_provider_config(kubeconfig_secret.name, kubeconfig_secret.key, sa_key)
self.compose_cluster_provider_config(
kubeconfig_secret.name,
kubeconfig_secret.key,
identity_ref=sa_key,
identity_type=_IDENTITY_TYPE_GCP,
)

backend_secrets = self.resolve_gke_backend_secrets(gke_ready=gke_ready, backend_exists=backend_exists)
if backend_secrets or backend_exists:
Expand Down Expand Up @@ -322,14 +327,20 @@ def compose_existing(self, existing: v1alpha1.Existing | None) -> None:

identity = existing.identitySecretRef

self.compose_cluster_provider_config(existing.secretRef.name, existing.secretRef.key, sa_key=identity)
self.compose_cluster_provider_config(
existing.secretRef.name,
existing.secretRef.key,
identity_ref=identity,
identity_type=(identity.type or _IDENTITY_TYPE_GCP) if identity else None,
)

backend_secrets = [
ssv1alpha1.Secret(type=_SECRET_TYPE_KUBECONFIG, name=existing.secretRef.name, key=existing.secretRef.key),
]
if identity:
backend_secrets.append(
ssv1alpha1.Secret(type=_SECRET_TYPE_GCP_SA_KEY, name=identity.name, key=identity.key),
# type defaults to GCP in the XRD; coalesce so it's never None.
ssv1alpha1.Secret(type=identity.type or _IDENTITY_TYPE_GCP, name=identity.name, key=identity.key),
)
self.compose_serving_stack(backend_secrets)

Expand Down Expand Up @@ -366,10 +377,16 @@ def compose_cluster_provider_config(
self,
kubeconfig_name: str,
kubeconfig_key: str | None,
sa_key: gkev1alpha1.Secret | v1alpha1.IdentitySecretRef | None = None,
identity_ref: gkev1alpha1.Secret | v1alpha1.IdentitySecretRef | None = None,
identity_type: str | None = None,
) -> None:
"""Compose a ClusterProviderConfig for provider-kubernetes so that
ModelReplicas can create Objects on the remote cluster."""
ModelReplicas can create Objects on the remote cluster.

When identity_ref is set, the ProviderConfig authenticates as the given
identity_type (the cloud IAM identity) on top of the kubeconfig instead
of relying on the kubeconfig's embedded credentials.
"""
cpc = k8scpcv1alpha1.ClusterProviderConfig(
metadata=metav1.ObjectMeta(name=resource.child_name(_name(self.xr.metadata), "cluster-kubeconfig")),
spec=k8scpcv1alpha1.Spec(
Expand All @@ -383,14 +400,14 @@ def compose_cluster_provider_config(
),
),
)
if sa_key:
if identity_ref:
cpc.spec.identity = k8scpcv1alpha1.Identity(
type=_IDENTITY_TYPE_GCP,
type=identity_type, # ty: ignore[invalid-argument-type] # value comes from the XRD/GKE identity-type enums
source="Secret",
secretRef=k8scpcv1alpha1.SecretRef(
namespace=_NAMESPACE_SYSTEM,
name=sa_key.name,
key=sa_key.key, # ty: ignore[invalid-argument-type] # XRD defaults the secret key
name=identity_ref.name,
key=identity_ref.key, # ty: ignore[invalid-argument-type] # XRD defaults the secret key
),
)
resource.update(
Expand Down
80 changes: 78 additions & 2 deletions functions/compose-inference-cluster/tests/test_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,77 @@ async def test_compose(self) -> None: # noqa: PLR0915
)
want1.requirements.resources["class-gpu-l4"].CopyFrom(class_selector)

# --- Case 1b: Existing cluster with a non-GCP identity threads the
# declared identity type into the CPC and the ServingStack. ---
req1b = fnv1.RunFunctionRequest(
observed=fnv1.State(
composite=fnv1.Resource(
resource=resource.dict_to_struct(
v1alpha1.InferenceCluster(
metadata=metav1.ObjectMeta(
name="test-cluster",
namespace="modelplane-system",
),
spec=v1alpha1.Spec(
cluster=v1alpha1.Cluster(
source="Existing",
existing=v1alpha1.Existing(
secretRef=v1alpha1.SecretRef(name="my-kubeconfig"),
identitySecretRef=v1alpha1.IdentitySecretRef(
name="nebius-creds",
key="credentials.json",
type="NebiusServiceAccountCredentials",
),
),
),
nodePools=[
v1alpha1.NodePool(
name="l4-pool",
className="gpu-l4",
nodeCount=2,
maxNodeCount=4,
),
],
),
).model_dump(exclude_none=True, mode="json")
),
),
),
)
req1b.required_resources["class-gpu-l4"].items.append(
fnv1.Resource(resource=resource.dict_to_struct(inference_class_l4))
)

# want1b mirrors want1 but with the Nebius identity on the CPC and an
# extra ServingStack identity secret of the same type.
want1b = fnv1.RunFunctionResponse()
want1b.CopyFrom(want1)
cpc1b = want1b.desired.resources["cluster-provider-config-kubernetes"]
cpc1b_dict = resource.struct_to_dict(cpc1b.resource)
cpc1b_dict["spec"]["identity"] = {
"type": "NebiusServiceAccountCredentials",
"source": "Secret",
"secretRef": {
"namespace": "modelplane-system",
"name": "nebius-creds",
"key": "credentials.json",
},
}
cpc1b.resource.CopyFrom(resource.dict_to_struct(cpc1b_dict))
backend1b = want1b.desired.resources["serving-stack"]
backend1b_dict = resource.struct_to_dict(backend1b.resource)
backend1b_dict["spec"]["secrets"].append(
{
"type": "NebiusServiceAccountCredentials",
"name": "nebius-creds",
"key": "credentials.json",
}
)
backend1b.resource.CopyFrom(resource.dict_to_struct(backend1b_dict))
# want1 gains the replica-guard requirement in place from the guard cases
# below, after this snapshot; add it here so want1b matches on its own.
want1b.requirements.resources["model-replicas"].CopyFrom(_replicas_selector("test-cluster"))

# --- Case 2: GKE cluster first pass - no observed GKE, classes resolved. ---
req2 = fnv1.RunFunctionRequest(
observed=fnv1.State(
Expand Down Expand Up @@ -1068,7 +1139,11 @@ async def test_compose(self) -> None: # noqa: PLR0915
"cache": {"storageClassName": "modelplane-rwx"},
"secrets": [
{"type": "Kubeconfig", "name": "test-cluster-kubeconfig-abcde", "key": "kubeconfig"},
{"type": "GCPServiceAccountKey", "name": "test-cluster-sa-key-fghij", "key": "credentials.json"},
{
"type": "GoogleApplicationCredentials",
"name": "test-cluster-sa-key-fghij",
"key": "credentials.json",
},
],
},
}
Expand Down Expand Up @@ -1225,7 +1300,7 @@ async def test_compose(self) -> None: # noqa: PLR0915
"key": "kubeconfig",
},
{
"type": "GCPServiceAccountKey",
"type": "GoogleApplicationCredentials",
"name": "test-cluster-sa-key-fghij",
"key": "credentials.json",
},
Expand Down Expand Up @@ -1442,6 +1517,7 @@ async def test_compose(self) -> None: # noqa: PLR0915

cases = [
Case(name="existing cluster with secrets composes backend and CPC", req=req1, want=want1),
Case(name="existing cluster with a non-GCP identity threads the identity type", req=req1b, want=want1b),
Case(name="GKE cluster first pass composes GKECluster XR only", req=req2, want=want2),
Case(name="existing cluster second pass with backend ready", req=req3, want=want3),
Case(name="EKS cluster first pass composes EKSCluster XR only", req=req4, want=want4),
Expand Down
Loading
Loading