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
13 changes: 11 additions & 2 deletions api/v1alpha1/model_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ type ModelSpec struct {
// Source defines where to obtain the model.
// For GGUF models: URL or path to a .gguf file.
// For MLX models: local directory path containing the model (config.json, weights).
// Supported schemes: http://, https://, file://, pvc://, or absolute paths.
// Supported schemes: http://, https://, file://, pvc://, hf://, s3://, or absolute paths.
// Examples:
// - https://huggingface.co/org/repo/resolve/main/model.gguf
// - file:///mnt/models/model.gguf
// - /mnt/models/model.gguf (air-gapped deployments)
// - pvc://my-models-pvc/path/to/model.gguf (pre-staged on a PersistentVolumeClaim)
// - s3://my-bucket/models/llama-3.1-8b-q4_k_m.gguf (S3-compatible object store)
// - /mnt/models/Llama-3.2-3B-Instruct-4bit (MLX model directory)
//
// file:// caveat for hybrid topologies: the controller pod must be
Expand All @@ -44,7 +45,7 @@ type ModelSpec struct {
// equivalent https://huggingface.co/.../<filename>.gguf URL which
// the runtime/init container resolves at deploy time.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`^(https?|file|pvc|hf)://.*|^/[^\s]+$|^[a-zA-Z0-9][\w\-\.\/]+$`
// +kubebuilder:validation:Pattern=`^(https?|file|pvc|hf|s3)://.*|^/[^\s]+$|^[a-zA-Z0-9][\w\-\.\/]+$`
Source string `json:"source"`

// SHA256 is the expected SHA256 hash of the model file for integrity verification.
Expand All @@ -53,6 +54,14 @@ type ModelSpec struct {
// +optional
SHA256 string `json:"sha256,omitempty"`

// SourceSecretRef names a Secret (in the Model's namespace) whose keys are
// wired as env into the model-downloader init container. Used by s3://
// sources for S3-compatible credentials/endpoint: AWS_ACCESS_KEY_ID,
// AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ENDPOINT_URL (path-style, e.g.
// https://minio.internal:9000 or https://s3.us-east-1.amazonaws.com).
// +optional
SourceSecretRef *corev1.LocalObjectReference `json:"sourceSecretRef,omitempty"`

// RefreshPolicy controls whether a cached model file is re-fetched when the
// upstream source changes.
//
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions charts/llmkube/templates/crds/models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -357,12 +357,13 @@ spec:
Source defines where to obtain the model.
For GGUF models: URL or path to a .gguf file.
For MLX models: local directory path containing the model (config.json, weights).
Supported schemes: http://, https://, file://, pvc://, or absolute paths.
Supported schemes: http://, https://, file://, pvc://, hf://, s3://, or absolute paths.
Examples:
- https://huggingface.co/org/repo/resolve/main/model.gguf
- file:///mnt/models/model.gguf
- /mnt/models/model.gguf (air-gapped deployments)
- pvc://my-models-pvc/path/to/model.gguf (pre-staged on a PersistentVolumeClaim)
- s3://my-bucket/models/llama-3.1-8b-q4_k_m.gguf (S3-compatible object store)
- /mnt/models/Llama-3.2-3B-Instruct-4bit (MLX model directory)

file:// caveat for hybrid topologies: the controller pod must be
Expand All @@ -374,8 +375,27 @@ spec:
tightly (#405). Workaround: pre-stage on a pvc://, or use the
equivalent https://huggingface.co/.../<filename>.gguf URL which
the runtime/init container resolves at deploy time.
pattern: ^(https?|file|pvc|hf)://.*|^/[^\s]+$|^[a-zA-Z0-9][\w\-\.\/]+$
pattern: ^(https?|file|pvc|hf|s3)://.*|^/[^\s]+$|^[a-zA-Z0-9][\w\-\.\/]+$
type: string
sourceSecretRef:
description: |-
SourceSecretRef names a Secret (in the Model's namespace) whose keys are
wired as env into the model-downloader init container. Used by s3://
sources for S3-compatible credentials/endpoint: AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ENDPOINT_URL (path-style, e.g.
https://minio.internal:9000 or https://s3.us-east-1.amazonaws.com).
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
required:
- source
type: object
Expand Down
24 changes: 22 additions & 2 deletions config/crd/bases/inference.llmkube.dev_models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -353,12 +353,13 @@ spec:
Source defines where to obtain the model.
For GGUF models: URL or path to a .gguf file.
For MLX models: local directory path containing the model (config.json, weights).
Supported schemes: http://, https://, file://, pvc://, or absolute paths.
Supported schemes: http://, https://, file://, pvc://, hf://, s3://, or absolute paths.
Examples:
- https://huggingface.co/org/repo/resolve/main/model.gguf
- file:///mnt/models/model.gguf
- /mnt/models/model.gguf (air-gapped deployments)
- pvc://my-models-pvc/path/to/model.gguf (pre-staged on a PersistentVolumeClaim)
- s3://my-bucket/models/llama-3.1-8b-q4_k_m.gguf (S3-compatible object store)
- /mnt/models/Llama-3.2-3B-Instruct-4bit (MLX model directory)

file:// caveat for hybrid topologies: the controller pod must be
Expand All @@ -370,8 +371,27 @@ spec:
tightly (#405). Workaround: pre-stage on a pvc://, or use the
equivalent https://huggingface.co/.../<filename>.gguf URL which
the runtime/init container resolves at deploy time.
pattern: ^(https?|file|pvc|hf)://.*|^/[^\s]+$|^[a-zA-Z0-9][\w\-\.\/]+$
pattern: ^(https?|file|pvc|hf|s3)://.*|^/[^\s]+$|^[a-zA-Z0-9][\w\-\.\/]+$
type: string
sourceSecretRef:
description: |-
SourceSecretRef names a Secret (in the Model's namespace) whose keys are
wired as env into the model-downloader init container. Used by s3://
sources for S3-compatible credentials/endpoint: AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ENDPOINT_URL (path-style, e.g.
https://minio.internal:9000 or https://s3.us-east-1.amazonaws.com).
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
required:
- source
type: object
Expand Down
31 changes: 31 additions & 0 deletions config/samples/model_s3_source.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Example Model with an S3 source. The init container fetches the model
# using curl --aws-sigv4 with credentials and endpoint from the Secret
# referenced by spec.sourceSecretRef.
#
# Full pull requires a live S3-compatible endpoint (e.g. MinIO) and is
# validated manually.
apiVersion: v1
kind: Secret
metadata:
name: s3-credentials
namespace: default
stringData:
AWS_ACCESS_KEY_ID: "minioadmin"
AWS_SECRET_ACCESS_KEY: "minioadmin"
AWS_REGION: "us-east-1"
AWS_ENDPOINT_URL: "https://minio.internal:9000"
---
apiVersion: inference.llmkube.dev/v1alpha1
kind: Model
metadata:
labels:
app.kubernetes.io/name: llmkube
app.kubernetes.io/managed-by: kustomize
name: llama-3.1-8b-s3
namespace: default
spec:
source: s3://my-bucket/models/llama-3.1-8b-q4_k_m.gguf
sourceSecretRef:
name: s3-credentials
format: gguf
quantization: Q4_K_M
22 changes: 11 additions & 11 deletions internal/controller/inferenceservice_storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1183,28 +1183,28 @@ var _ = Describe("resolveCacheMode", func() {

var _ = Describe("buildModelInitCommand", func() {
It("should generate cached remote download command with env var references", func() {
cmd := buildModelInitCommand(false, true, RefreshPolicyIfNotPresent)
cmd := buildModelInitCommand(false, false, true, RefreshPolicyIfNotPresent)
Expect(cmd).To(ContainSubstring(`mkdir -p "$CACHE_DIR"`))
Expect(cmd).To(ContainSubstring(`"$MODEL_PATH"`))
Expect(cmd).To(ContainSubstring("curl -f -L"))
Expect(cmd).To(ContainSubstring(`"$MODEL_SOURCE"`))
})

It("should generate cached local copy command", func() {
cmd := buildModelInitCommand(true, true, RefreshPolicyIfNotPresent)
cmd := buildModelInitCommand(true, false, true, RefreshPolicyIfNotPresent)
Expect(cmd).To(ContainSubstring(`mkdir -p "$CACHE_DIR"`))
Expect(cmd).To(ContainSubstring("cp /host-model/model.gguf"))
Expect(cmd).To(ContainSubstring(`"$MODEL_PATH"`))
})

It("should generate error exit for uncached local source", func() {
cmd := buildModelInitCommand(true, false, RefreshPolicyIfNotPresent)
cmd := buildModelInitCommand(true, false, false, RefreshPolicyIfNotPresent)
Expect(cmd).To(ContainSubstring("ERROR: Local model source requires model cache"))
Expect(cmd).To(ContainSubstring("exit 1"))
})

It("should generate uncached remote download command with env var references", func() {
cmd := buildModelInitCommand(false, false, RefreshPolicyIfNotPresent)
cmd := buildModelInitCommand(false, false, false, RefreshPolicyIfNotPresent)
Expect(cmd).To(ContainSubstring("curl -f -L"))
Expect(cmd).To(ContainSubstring(`"$MODEL_SOURCE"`))
Expect(cmd).To(ContainSubstring(`"$MODEL_PATH"`))
Expand All @@ -1215,7 +1215,7 @@ var _ = Describe("buildModelInitCommand", func() {
// Verify that a malicious source cannot appear in the shell script.
// The command is a static template with env var references only.
maliciousSource := `https://evil.com/$(touch /pwned).gguf`
cmd := buildModelInitCommand(false, true, RefreshPolicyIfNotPresent)
cmd := buildModelInitCommand(false, false, true, RefreshPolicyIfNotPresent)
Expect(cmd).NotTo(ContainSubstring(maliciousSource))
Expect(cmd).NotTo(ContainSubstring("touch"))
Expect(cmd).NotTo(ContainSubstring("evil.com"))
Expand All @@ -1228,7 +1228,7 @@ var _ = Describe("buildModelInitCommand", func() {

Context("RefreshPolicy=OnChange (http/https revalidation, issue #619)", func() {
It("cached: emits curl conditional GET against an etag marker beside the model", func() {
cmd := buildModelInitCommand(false, true, RefreshPolicyOnChange)
cmd := buildModelInitCommand(false, false, true, RefreshPolicyOnChange)
// Still provisions the cache dir like IfNotPresent.
Expect(cmd).To(ContainSubstring(`mkdir -p "$CACHE_DIR"`))
// Conditional GET via curl's native ETag flags.
Expand All @@ -1243,7 +1243,7 @@ var _ = Describe("buildModelInitCommand", func() {
})

It("uncached: emits the same conditional GET without the cache dir mkdir", func() {
cmd := buildModelInitCommand(false, false, RefreshPolicyOnChange)
cmd := buildModelInitCommand(false, false, false, RefreshPolicyOnChange)
Expect(cmd).To(ContainSubstring("--etag-compare"))
Expect(cmd).To(ContainSubstring("--etag-save"))
Expect(cmd).To(ContainSubstring(`"$MODEL_SOURCE"`))
Expand All @@ -1252,7 +1252,7 @@ var _ = Describe("buildModelInitCommand", func() {
})

It("keeps the cached file and exits 0 when revalidation is unreachable", func() {
cmd := buildModelInitCommand(false, true, RefreshPolicyOnChange)
cmd := buildModelInitCommand(false, false, true, RefreshPolicyOnChange)
// Robustness guard: a network blip must not take down a running
// InferenceService on pod restart.
Expect(cmd).To(ContainSubstring(`[ -f "$MODEL_PATH" ]`))
Expand All @@ -1265,14 +1265,14 @@ var _ = Describe("buildModelInitCommand", func() {
It("does not change the local (file://) init path", func() {
// file:// sources are owned by the controller (#635); the init
// container path must be identical regardless of RefreshPolicy.
ifNotPresent := buildModelInitCommand(true, true, RefreshPolicyIfNotPresent)
onChange := buildModelInitCommand(true, true, RefreshPolicyOnChange)
ifNotPresent := buildModelInitCommand(true, false, true, RefreshPolicyIfNotPresent)
onChange := buildModelInitCommand(true, false, true, RefreshPolicyOnChange)
Expect(onChange).To(Equal(ifNotPresent))
Expect(onChange).NotTo(ContainSubstring("--etag-compare"))
})

It("does not contain user-controlled values in the OnChange command string", func() {
cmd := buildModelInitCommand(false, true, RefreshPolicyOnChange)
cmd := buildModelInitCommand(false, false, true, RefreshPolicyOnChange)
Expect(cmd).NotTo(ContainSubstring("evil.com"))
Expect(cmd).NotTo(ContainSubstring("touch"))
})
Expand Down
40 changes: 36 additions & 4 deletions internal/controller/model_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,14 @@ func addCACertVolume(volumes *[]corev1.Volume, mounts *[]corev1.VolumeMount, cmd
*cmd = fmt.Sprintf("export CURL_CA_BUNDLE=/custom-certs/$(ls /custom-certs | grep -v '^\\.' | head -n 1) && %s", *cmd)
}

func buildModelInitCommand(isLocal, useCache bool, refreshPolicy string) string {
func buildModelInitCommand(isLocal, isS3, useCache bool, refreshPolicy string) string {
if useCache {
if isLocal {
return `mkdir -p "$CACHE_DIR" && if [ ! -f "$MODEL_PATH" ]; then echo 'Copying model from local source...'; cp /host-model/model.gguf "$MODEL_PATH" && echo 'Model copied successfully'; else echo 'Model already cached, skipping copy'; fi`
}
if isS3 {
return `mkdir -p "$CACHE_DIR" && if [ ! -f "$MODEL_PATH" ]; then echo 'Downloading model from S3...'; curl --aws-sigv4 "aws:amz:${AWS_REGION}:s3" -u "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" -f -L -o "$MODEL_PATH" "${AWS_ENDPOINT_URL}/${S3_BUCKET}/${S3_KEY}" && echo 'Model downloaded successfully'; else echo 'Model already cached, skipping download'; fi`
}
if refreshPolicy == RefreshPolicyOnChange {
return "mkdir -p \"$CACHE_DIR\" && " + remoteRevalidateScript
}
Expand All @@ -177,6 +180,9 @@ func buildModelInitCommand(isLocal, useCache bool, refreshPolicy string) string
if isLocal {
return `echo 'ERROR: Local model source requires model cache to be configured.'; exit 1`
}
if isS3 {
return `if [ ! -f "$MODEL_PATH" ]; then echo 'Downloading model from S3...'; curl --aws-sigv4 "aws:amz:${AWS_REGION}:s3" -u "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" -f -L -o "$MODEL_PATH" "${AWS_ENDPOINT_URL}/${S3_BUCKET}/${S3_KEY}" && echo 'Model downloaded successfully'; else echo 'Model already exists, skipping download'; fi`
}
if refreshPolicy == RefreshPolicyOnChange {
return remoteRevalidateScript
}
Expand Down Expand Up @@ -210,11 +216,35 @@ const remoteRevalidateScript = `ETAG_MARKER="$(dirname "$MODEL_PATH")/.$(basenam
`fi`

func modelInitEnvVars(source, cacheDir, modelPath string) []corev1.EnvVar {
return []corev1.EnvVar{
envs := []corev1.EnvVar{
{Name: "MODEL_SOURCE", Value: source},
{Name: "CACHE_DIR", Value: cacheDir},
{Name: "MODEL_PATH", Value: modelPath},
}
if isS3Source(source) {
bucket, key, err := parseS3Source(source)
if err == nil {
envs = append(envs, corev1.EnvVar{Name: "S3_BUCKET", Value: bucket}, corev1.EnvVar{Name: "S3_KEY", Value: key})
}
}
return envs
}

// modelEnvFrom returns EnvFrom entries for the model-downloader init container.
// When the model has a SourceSecretRef, it pulls AWS_* env vars (credentials,
// endpoint, region) from the referenced Secret. Returns nil when no secret ref
// is configured.
func modelEnvFrom(model *inferencev1alpha1.Model) []corev1.EnvFromSource {
if model.Spec.SourceSecretRef == nil {
return nil
}
return []corev1.EnvFromSource{
{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: *model.Spec.SourceSecretRef,
},
},
}
}

// resolveHFSourceURL converts hf://repo-id sources to their huggingface.co
Expand Down Expand Up @@ -589,7 +619,7 @@ func buildCachedStorageConfig(model *inferencev1alpha1.Model, isvc *inferencev1a
})
}

cmd := buildModelInitCommand(isLocalModelSource(model.Spec.Source), true, model.Spec.RefreshPolicy)
cmd := buildModelInitCommand(isLocalModelSource(model.Spec.Source), isS3Source(model.Spec.Source), true, model.Spec.RefreshPolicy)
env := modelInitEnvVars(model.Spec.Source, cacheDir, modelPath)
addCACertVolume(&volumes, &initVolumeMounts, &cmd, caCertConfigMap)

Expand All @@ -600,6 +630,7 @@ func buildCachedStorageConfig(model *inferencev1alpha1.Model, isvc *inferencev1a
Image: initContainerImage,
Command: []string{"sh", "-c", cmd},
Env: env,
EnvFrom: modelEnvFrom(model),
VolumeMounts: initVolumeMounts,
SecurityContext: initContainerSecurityContext(isvc),
},
Expand Down Expand Up @@ -669,7 +700,7 @@ func buildEmptyDirStorageConfig(model *inferencev1alpha1.Model, isvc *inferencev
},
}

cmd := buildModelInitCommand(isLocalModelSource(model.Spec.Source), false, model.Spec.RefreshPolicy)
cmd := buildModelInitCommand(isLocalModelSource(model.Spec.Source), isS3Source(model.Spec.Source), false, model.Spec.RefreshPolicy)
env := modelInitEnvVars(model.Spec.Source, "", modelPath)
addCACertVolume(&volumes, &initVolumeMounts, &cmd, caCertConfigMap)

Expand All @@ -681,6 +712,7 @@ func buildEmptyDirStorageConfig(model *inferencev1alpha1.Model, isvc *inferencev
Image: initContainerImage,
Command: []string{"sh", "-c", cmd},
Env: env,
EnvFrom: modelEnvFrom(model),
VolumeMounts: initVolumeMounts,
SecurityContext: initContainerSecurityContext(isvc),
},
Expand Down
Loading