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
5 changes: 3 additions & 2 deletions charts/modeldeployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@ letting KEDA fail later when it creates the HPA:

## Naming conventions

- InferencePool: `<name>-inferencepool`
- EPP Deployment / Service / SA / Role / ConfigMap: `<name>-inferencepool-epp`
- InferencePool, EPP Service and the EPP pods' `inferencepool` label: `<name>`
(these must fit 63 characters, so `name` is limited to 63 characters)
- EPP Deployment / SA / Role / RoleBinding / ConfigMap: `<name>-inferencepool-epp`
- HTTPRoute: `<name>-route`

## Example
Expand Down
18 changes: 14 additions & 4 deletions charts/modeldeployment/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,27 @@ Gateway that modelharness provisions in the workload namespace.
{{- end }}

{{/*
Derived InferencePool name, matching KAITO's naming convention.
InferencePool name. Equals the deployment name: Istio stamps it as the
istio.io/inferencepool-name label value on the InferencePool's shadow
Service, so it must fit the 63-character label value limit.
*/}}
{{- define "modeldeployment.inferencePoolName" -}}
{{ include "modeldeployment.name" . }}-inferencepool
{{- include "modeldeployment.name" . -}}
{{- end }}

{{/*
Derived EPP service name (used as the DestinationRule host).
EPP Service name, also used as the `inferencepool` selector label value.
Equals the deployment name so it fits the 63-character limit of both.
*/}}
{{- define "modeldeployment.eppServiceName" -}}
{{ include "modeldeployment.inferencePoolName" . }}-epp
{{- include "modeldeployment.name" . -}}
{{- end }}

{{/*
Name of the EPP Deployment / ServiceAccount / Role / RoleBinding / ConfigMap.
*/}}
{{- define "modeldeployment.eppName" -}}
{{ include "modeldeployment.name" . }}-inferencepool-epp
{{- end }}

{{/*
Expand Down
2 changes: 1 addition & 1 deletion charts/modeldeployment/templates/epp-configmap.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
namespace: {{ include "modeldeployment.namespace" . }}
labels:
{{- include "modeldeployment.labels" . | nindent 4 }}
Expand Down
6 changes: 3 additions & 3 deletions charts/modeldeployment/templates/epp-deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
namespace: {{ include "modeldeployment.namespace" . }}
labels:
{{- include "modeldeployment.labels" . | nindent 4 }}
Expand Down Expand Up @@ -36,7 +36,7 @@ spec:
checksum/epp-flow-control: {{ include "modeldeployment.eppFlowControlSettings" . | sha256sum }}
{{- end }}
spec:
serviceAccountName: {{ include "modeldeployment.eppServiceName" . }}
serviceAccountName: {{ include "modeldeployment.eppName" . }}
terminationGracePeriodSeconds: 130
{{- with .Values.epp.tolerations }}
tolerations:
Expand Down Expand Up @@ -94,4 +94,4 @@ spec:
volumes:
- name: plugins-config-volume
configMap:
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
10 changes: 5 additions & 5 deletions charts/modeldeployment/templates/epp-rbac.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
namespace: {{ include "modeldeployment.namespace" . }}
labels:
{{- include "modeldeployment.labels" . | nindent 4 }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
namespace: {{ include "modeldeployment.namespace" . }}
labels:
{{- include "modeldeployment.labels" . | nindent 4 }}
Expand All @@ -27,15 +27,15 @@ rules:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
namespace: {{ include "modeldeployment.namespace" . }}
labels:
{{- include "modeldeployment.labels" . | nindent 4 }}
subjects:
- kind: ServiceAccount
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
namespace: {{ include "modeldeployment.namespace" . }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "modeldeployment.eppServiceName" . }}
name: {{ include "modeldeployment.eppName" . }}
3 changes: 2 additions & 1 deletion charts/modeldeployment/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"properties": {
"name": {
"type": "string",
"description": "Deployment / InferenceSet name. Empty falls back to .Release.Name."
"maxLength": 63,
"description": "Deployment / InferenceSet name, also used as the InferencePool and EPP Service name and the EPP `inferencepool` selector label value, hence at most 63 characters. Empty falls back to .Release.Name."
},
"namespace": {
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ the production App Routing provisioning contract.
- [`setup.go`](utils/setup.go) — `EnsureNamespace` (provisions the workload namespace + modelharness), `DeleteNamespace`, `SetupInferenceSetsWithRouting`, `TeardownInferenceSetsWithRouting`.
- [`http.go`](utils/http.go) — authenticated `SendGatewayRequest` with `SendChat` / `SendModels` shortcuts against a `deploy.GatewayEndpoint`, taking `RequestOption`s (`WithPrompt`, `WithAuth`, `WithoutAuth`, `WithHeader`, `WithMethod`, `WithTransportRetry`, `WithOriginPath`, `WithTimeout`).
- [`deployer.go`](utils/deployer.go) — `SetDeployer` / `CurrentDeployer` plus the `InstallModelDeployment`, `UpgradeModelDeployment`, `UninstallModelDeployment`, `InstallModelHarness`, `UninstallModelHarness`, `NamespaceAuthHeaders` helpers that delegate to the active backend (see [Deployment backends](#deployment-backends)).
- [`inference.go`](utils/inference.go) — `WaitForInferenceSetReady`, `EPPServiceName`, snapshot/diff helpers.
- [`inference.go`](utils/inference.go) — `WaitForInferenceSetReady`, `EPPDeploymentName`, `EPPSelector`, snapshot/diff helpers.
- [`metrics.go`](utils/metrics.go), [`cluster.go`](utils/cluster.go), [`dynamic.go`](utils/dynamic.go), [`ginkgo.go`](utils/ginkgo.go).

`cases.go`:
Expand Down
7 changes: 4 additions & 3 deletions test/e2e/cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,10 @@ const (
// unique across the entire table so Helm releases never collide.
//
// Conventions:
// - Name: deployment / Helm release name. AND the value carried in the
// `model` field of OpenAI-style requests (matched by the HTTPRoute as
// X-Gateway-Model-Name).
// - Name: deployment name (the Helm backend uses it as the release name,
// shortened when it exceeds Helm's 53-character limit). AND the value
// carried in the `model` field of OpenAI-style requests (matched by the
// HTTPRoute as X-Gateway-Model-Name).
// - Model: inference preset name, written to the InferenceSet's
// spec.template.inference.preset.name.
// - Namespace: per-case namespace — the suite installs into this
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/control_plane_error_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ var _ = Describe("Control-plane Error Reporter",
})

It("emits inferencesetEPPNotReady when the InferenceSet EPP is scaled to zero", func() {
eppName := utils.EPPServiceName(modelName)
eppName := utils.EPPDeploymentName(modelName)
desired, _, err := utils.GetDeploymentReplicas(ctx, caseNS, eppName)
Expect(err).NotTo(HaveOccurred())
baseline, err := utils.ReporterEventBaseline(ctx, "inferencesetEPPNotReady", caseNS)
Expand Down
90 changes: 90 additions & 0 deletions test/e2e/deploy/helm/chart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,22 @@ limitations under the License.
package helm

import (
"encoding/json"
"maps"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation"
"sigs.k8s.io/yaml"
)

var maxLengthErrorRe = regexp.MustCompile(`name: String length must be less than or equal to 63|'/name': maxLength: got \d+, want 63`)

func TestModelDeploymentScalingAnnotations(t *testing.T) {
if _, err := exec.LookPath("helm"); err != nil {
t.Skip("helm is required for chart rendering tests")
Expand Down Expand Up @@ -188,6 +195,89 @@ func TestModelDeploymentScalingValidation(t *testing.T) {
}
}

// The deployment name feeds the EPP Service name, the `inferencepool`
// selector label value and the InferencePool name (which Istio copies into a
// label on the pool's shadow Service), all capped at 63 characters. A
// 63-character name must render objects that satisfy those limits, and a
// 64-character name must be rejected by the chart.
func TestModelDeploymentNameLength(t *testing.T) {
if _, err := exec.LookPath("helm"); err != nil {
t.Skip("helm is required for chart rendering tests")
}
chart := filepath.Join("..", "..", "..", "..", "charts", "modeldeployment")
render := func(name string) ([]byte, error) {
return exec.Command("helm", "template", "model", chart,
"--set", "model=test-model", "--set", "namespace=ns", "--set", "name="+name).CombinedOutput()
}

// Helm's JSON schema validator words the error differently across releases:
// "name: String length must be less than or equal to 63" before v3.18,
// "at '/name': maxLength: got 64, want 63" from v3.18 on.
output, err := render(strings.Repeat("a", 64))
if err == nil || !maxLengthErrorRe.Match(output) {
t.Errorf("expected a 64-character name to be rejected, got %v\n%s", err, output)
}

output, err = render(strings.Repeat("a", 63))
if err != nil {
t.Fatalf("render with a 63-character name: %v\n%s", err, output)
}
var kinds []string
for _, doc := range strings.Split(string(output), "\n---") {
var obj struct {
Kind string `json:"kind"`
Metadata metav1.ObjectMeta `json:"metadata"`
Spec struct {
Selector json.RawMessage `json:"selector"`
Template struct {
Metadata metav1.ObjectMeta `json:"metadata"`
} `json:"template"`
} `json:"spec"`
}
if err := yaml.Unmarshal([]byte(doc), &obj); err != nil {
t.Fatalf("parse rendered object: %v\n%s", err, doc)
}
if obj.Kind == "" {
continue
}
kinds = append(kinds, obj.Kind)
labels := map[string]string{}
maps.Copy(labels, obj.Metadata.Labels)
maps.Copy(labels, obj.Spec.Template.Metadata.Labels)
switch obj.Kind {
case "Service":
if errs := validation.IsDNS1035Label(obj.Metadata.Name); len(errs) > 0 {
t.Errorf("Service name %q: %v", obj.Metadata.Name, errs)
}
var selector map[string]string
if err := json.Unmarshal(obj.Spec.Selector, &selector); err != nil {
t.Fatalf("parse Service selector: %v", err)
}
maps.Copy(labels, selector)
case "Deployment":
var selector metav1.LabelSelector
if err := json.Unmarshal(obj.Spec.Selector, &selector); err != nil {
t.Fatalf("parse Deployment selector: %v", err)
}
maps.Copy(labels, selector.MatchLabels)
case "InferencePool":
if len(obj.Metadata.Name) > 63 {
t.Errorf("InferencePool name %q exceeds 63 characters", obj.Metadata.Name)
}
}
for key, value := range labels {
if len(value) > 63 {
t.Errorf("%s %s: label %s=%q exceeds 63 characters", obj.Kind, obj.Metadata.Name, key, value)
}
}
}
for _, kind := range []string{"Service", "Deployment", "InferencePool"} {
if !slices.Contains(kinds, kind) {
t.Errorf("rendered chart has no %s; rendered kinds: %v", kind, kinds)
}
}
}

func renderModelDeployment(t *testing.T, values ...string) ([]byte, error) {
t.Helper()
chart := filepath.Join("..", "..", "..", "..", "charts", "modeldeployment")
Expand Down
31 changes: 24 additions & 7 deletions test/e2e/deploy/helm/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ package helm

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -287,8 +289,9 @@ func isNotFound(out []byte) bool {

// InstallModelDeployment runs `helm upgrade --install` for the modeldeployment
// chart with the supplied values. It is idempotent — re-running it on an
// existing release reconciles to the new values. The release name equals
// values.Name and the release namespace equals values.Namespace.
// existing release reconciles to the new values. The release name is
// modelDeploymentReleaseName(values.Name) and the release namespace equals
// values.Namespace.
func (d *Deployer) InstallModelDeployment(ctx context.Context, values deploy.ModelDeploymentValues) error {
if err := values.Validate(); err != nil {
return err
Expand All @@ -304,7 +307,7 @@ func (d *Deployer) InstallModelDeployment(ctx context.Context, values deploy.Mod
}

args := []string{
"upgrade", "--install", values.Name, chart,
"upgrade", "--install", modelDeploymentReleaseName(values.Name), chart,
"--namespace", values.Namespace,
"--create-namespace",
}
Expand Down Expand Up @@ -335,7 +338,7 @@ func (d *Deployer) UpgradeModelDeployment(ctx context.Context, values deploy.Mod
return err
}

args := []string{"upgrade", values.Name, chart, "--namespace", values.Namespace}
args := []string{"upgrade", modelDeploymentReleaseName(values.Name), chart, "--namespace", values.Namespace}
args = append(args, setArgs(values)...)
args = append(args, gatewaySetArgs(values.Gateway)...)

Expand All @@ -357,20 +360,34 @@ func gatewaySetArgs(values deploy.GatewayValues) []string {
return args
}

// UninstallModelDeployment runs `helm uninstall` for the named release.
// Missing releases are treated as success (so cleanup is idempotent).
// UninstallModelDeployment runs `helm uninstall` for the named model
// deployment's release. Missing releases are treated as success (so cleanup
// is idempotent).
func (d *Deployer) UninstallModelDeployment(ctx context.Context, name, namespace string) error {
if name == "" {
return fmt.Errorf("modeldeployment: name is required")
}
args := []string{"uninstall", name, "--namespace", namespace, "--ignore-not-found", "--wait"}
args := []string{"uninstall", modelDeploymentReleaseName(name), "--namespace", namespace, "--ignore-not-found", "--wait"}

if out, err := d.run(ctx, args...); err != nil {
return fmt.Errorf("helm uninstall %s failed: %w\n%s", name, err, string(out))
}
return nil
}

// modelDeploymentReleaseName returns the Helm release name for a model
// deployment. It is the deployment name, except that names longer than Helm's
// 53-character release name limit (deployment names may reach 63 characters)
// are truncated and suffixed with a hash of the full name so they stay unique.
func modelDeploymentReleaseName(name string) string {
const maxReleaseNameLength, hashLength = 53, 8
if len(name) <= maxReleaseNameLength {
return name
}
sum := sha256.Sum256([]byte(name))
return name[:maxReleaseNameLength-hashLength-1] + "-" + hex.EncodeToString(sum[:])[:hashLength]
}

// setArgs builds the `--set` arguments for the modeldeployment chart.
func setArgs(v deploy.ModelDeploymentValues) []string {
args := []string{
Expand Down
41 changes: 41 additions & 0 deletions test/e2e/deploy/helm/helm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,47 @@ func assertNamespaceLabelled(t *testing.T, calls [][]string, namespace string) {

// Upgrade must not double as create: a mistyped release name has to fail
// rather than silently provision a second deployment.
// Deployment names may reach 63 characters, but Helm release names are capped
// at 53. Install, upgrade and uninstall must all target the same shortened
// release, while the chart still receives the full deployment name.
func TestLongDeploymentNameUsesShortenedRelease(t *testing.T) {
d, calls := newTestDeployer(t, nil)

name := strings.Repeat("a", 63)
values := deploy.ModelDeploymentValues{
Name: name, Namespace: "e2e-ns", Model: "phi-4-mini-instruct", Replicas: 1,
}
if err := d.InstallModelDeployment(context.Background(), values); err != nil {
t.Fatalf("InstallModelDeployment: %v", err)
}
if err := d.UpgradeModelDeployment(context.Background(), values); err != nil {
t.Fatalf("UpgradeModelDeployment: %v", err)
}
if err := d.UninstallModelDeployment(context.Background(), name, "e2e-ns"); err != nil {
t.Fatalf("UninstallModelDeployment: %v", err)
}

release := modelDeploymentReleaseName(name)
if len(release) != 53 || !strings.HasPrefix(release, strings.Repeat("a", 44)+"-") {
t.Fatalf("release name %q is not a 53-character hash-suffixed prefix", release)
}
if got := modelDeploymentReleaseName(strings.Repeat("a", 62) + "b"); got == release {
t.Errorf("names differing past the truncation point share release %q", got)
}
if got := modelDeploymentReleaseName("phi"); got != "phi" {
t.Errorf("short name release = %q, want %q", got, "phi")
}

install, upgrade, uninstall := (*calls)[0], (*calls)[1], (*calls)[2]
if install[2] != release || upgrade[1] != release || uninstall[1] != release {
t.Errorf("release mismatch: install %q, upgrade %q, uninstall %q, want %q",
install[2], upgrade[1], uninstall[1], release)
}
if !hasArg(install, "name="+name) || !hasArg(upgrade, "name="+name) {
t.Errorf("chart must receive the full deployment name: install %v, upgrade %v", install, upgrade)
}
}

func TestUpgradeModelDeploymentDoesNotInstall(t *testing.T) {
d, calls := newTestDeployer(t, nil)

Expand Down
Loading
Loading