Skip to content
Open
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
42 changes: 40 additions & 2 deletions pkg/suse-ai-lifecycle-manager/services/fleet-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,44 @@ export function buildBundleName(release: string, namespace: string): string {
return `suse-ai-${ release }-${ namespace }`.replace(/[^a-z0-9-]/g, '-').slice(0, 63);
}

// 53 = 63 (K8s DNS-1123 label max) − 10 bytes Helm reserves for generated
// suffixes. Fleet validates spec.helm.releaseName against this.
const HELM_RELEASE_NAME_MAX = 53; // Helm/Fleet reject release names longer than this.
const HELM_HASH_LEN = 6; // base36 suffix; 36^6 ≈ 2.2e9 distinct values, ample for collision avoidance.

// Fleet validates spec.helm.releaseName against Helm's 53-byte limit, but a
// bundle name can be up to 63 (a valid K8s object name). Cap the release name,
// appending a short deterministic hash when truncating so distinct bundle names
// don't collide on the same prefix. The result is always a valid DNS-1123 label
// (no leading/trailing '-'), even for pathological inputs.
//
// Uses the same algorithm (FNV-1a / base36) as the operator's Go capReleaseName
// so both sides produce identical names for the same input. They don't strictly
// need to match — a single install's releaseName is produced by exactly one side,
// and the operator looks workloads up by bundle (object) name, never by
// releaseName — but keeping them aligned avoids confusion.
//
// Callers pass ASCII names (buildBundleName strips non-[a-z0-9-]), so .length
// (UTF-16 units) equals the byte count here; this is not safe for arbitrary
// multibyte input.
export function capReleaseName(name: string): string {
if (name.length <= HELM_RELEASE_NAME_MAX) return name;
const hash = fnv1a32(name).toString(36).slice(0, HELM_HASH_LEN);
const head = name.slice(0, HELM_RELEASE_NAME_MAX - hash.length - 1).replace(/^-+|-+$/g, '');
return head ? `${ head }-${ hash }` : hash;
}

// fnv1a32 is the 32-bit FNV-1a hash, matching Go's hash/fnv New32a() byte-for-byte
// for ASCII input. Math.imul does the 32-bit multiply without precision loss.
function fnv1a32(s: string): number {
let h = 0x811c9dc5; // offset basis (2166136261)
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193); // FNV prime (16777619)
}
return h >>> 0;
}

interface ClientSecretRef { name: string; namespace: string; }

// Read the clientSecret ref from a Rancher ClusterRepo resource.
Expand Down Expand Up @@ -148,7 +186,7 @@ export function buildFleetBundleYAML(params: {
...(isOCI ? {} : { chart: params.chartName }),
version: params.chartVersion,
repo: isOCI ? `${ params.chartRepoUrl }/${ params.chartName }` : params.chartRepoUrl,
releaseName: params.bundleName,
releaseName: capReleaseName(params.bundleName),
values,
// Disable Fleet's ${ } value templating: we resolve all values ourselves,
// and upstream charts legitimately use ${ } (e.g. OTel ${env:MY_POD_IP}),
Expand Down Expand Up @@ -221,7 +259,7 @@ export async function createFleetBundle(store: any, params: FleetBundleParams):
...(isOCI ? {} : { chart: params.chartName }),
version: params.chartVersion,
repo: ociRepo,
releaseName: params.bundleName,
releaseName: capReleaseName(params.bundleName),
values: addPullSecretsToValues(params.values, pullSecretNames, params.library),
// Disable Fleet's ${ } value templating: we resolve all values ourselves,
// and upstream charts legitimately use ${ } (e.g. OTel ${env:MY_POD_IP}),
Expand Down
41 changes: 39 additions & 2 deletions suse-ai-operator/internal/controller/aiworkload/blueprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"hash/fnv"
"regexp"
"strconv"
"strings"

corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -103,7 +105,7 @@ func (r *AIWorkloadReconciler) ensureBlueprintHelmOp(
isOCI := strings.HasPrefix(repoInfo.URL, "oci://")
helmSpec := map[string]any{
"version": c.ChartVersion,
"releaseName": bundleName,
"releaseName": capReleaseName(bundleName),
// Disable Fleet's ${ } value templating: we resolve all values ourselves,
// and upstream charts legitimately use ${ } (e.g. OTel ${env:MY_POD_IP}),
// which Fleet would otherwise mis-parse as a template function.
Expand Down Expand Up @@ -294,6 +296,41 @@ func repoURLToHost(url string) string {
return host
}

const (
// 53 = 63 (K8s DNS-1123 label max) − 10 bytes Helm reserves for generated
// suffixes. Fleet validates spec.helm.releaseName against this.
helmReleaseNameMax = 53 // Helm/Fleet reject release names longer than this.
helmHashLen = 6 // base36 suffix; 36^6 ≈ 2.2e9 distinct values, ample for collision avoidance.
)

// capReleaseName mirrors the dashboard's release-name capping: Helm/Fleet reject
// release names longer than 53 bytes, while the bundle (object) name may be up to
// 63. Append a short hash when truncating to avoid collisions on a shared prefix.
// The result is always a valid DNS-1123 label (no leading/trailing '-'), even
// for pathological inputs.
//
// This need NOT match the dashboard's TS capReleaseName byte-for-byte: a single
// install's releaseName is produced by exactly one side, and the operator looks
// workloads up by bundle (object) name, never by releaseName.
func capReleaseName(name string) string {
if len(name) <= helmReleaseNameMax {
return name
}
h := fnv.New32a()
_, _ = h.Write([]byte(name))
suffix := strconv.FormatUint(uint64(h.Sum32()), 36)
// base36(uint32) is 1–7 chars; cap to helmHashLen. The length guard is
// required: slicing a shorter suffix (e.g. "5") to [:helmHashLen] would panic.
if len(suffix) > helmHashLen {
suffix = suffix[:helmHashLen]
}
head := strings.Trim(name[:helmReleaseNameMax-len(suffix)-1], "-")
if head == "" {
return suffix
}
return head + "-" + suffix
}

// dockerAuthEntry builds the auth object for a single registry in a dockerconfigjson auths map.
func dockerAuthEntry(username, password string) map[string]any {
return map[string]any{
Expand Down Expand Up @@ -347,7 +384,7 @@ func (r *AIWorkloadReconciler) ensureBlueprintGitFile(
isOCI := strings.HasPrefix(repoInfo.URL, "oci://")
helmSpec := map[string]any{
"version": c.ChartVersion,
"releaseName": bundleName,
"releaseName": capReleaseName(bundleName),
// Disable Fleet's ${ } value templating: we resolve all values ourselves,
// and upstream charts legitimately use ${ } (e.g. OTel ${env:MY_POD_IP}),
// which Fleet would otherwise mis-parse as a template function.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package aiworkload

import (
"strings"
"testing"
)

func TestCapReleaseName(t *testing.T) {
// Names within Helm's 53-byte limit are returned unchanged.
t.Run("passthrough when within limit", func(t *testing.T) {
const short = "suse-ai-milvus-milvus-system"
if got := capReleaseName(short); got != short {
t.Errorf("expected unchanged %q, got %q", short, got)
}
})

// The 56-byte name from the reported NVIDIA install must be capped.
t.Run("caps over-long name to 53 bytes", func(t *testing.T) {
const long = "suse-ai-nvidia-blueprint-rag-nvidia-blueprint-rag-system" // 56 bytes
assertValidCappedName(t, capReleaseName(long))
})

// A name exactly at the boundary is left intact.
t.Run("exactly 53 bytes is unchanged", func(t *testing.T) {
name := repeat('a', helmReleaseNameMax)
if got := capReleaseName(name); got != name {
t.Errorf("expected unchanged 53-byte name, got %q", got)
}
})

// Distinct over-long inputs sharing a long prefix must not collide.
t.Run("distinct long inputs do not collide", func(t *testing.T) {
a := "suse-ai-nvidia-blueprint-rag-deployment-one-extra-namespace"
b := "suse-ai-nvidia-blueprint-rag-deployment-two-extra-namespace"
if capReleaseName(a) == capReleaseName(b) {
t.Errorf("expected distinct outputs for distinct inputs, both -> %q", capReleaseName(a))
}
})

// Pathological inputs must still yield a valid DNS-1123 label (no leading/
// trailing '-'), not just a short-enough string.
t.Run("pathological inputs stay valid", func(t *testing.T) {
cases := []string{
repeat('-', 54), // all dashes -> head empties out
"-" + repeat('a', 60), // leading dash preserved by old impl
repeat('a', 200), // very long
"suse-ai-" + repeat('-', 60), // valid prefix, dash tail at the cut
repeat('a', 47) + "------------------------", // content then dash run at the cut
}
for _, in := range cases {
assertValidCappedName(t, capReleaseName(in))
}
})
}

// assertValidCappedName checks the invariants every capped name must satisfy.
func assertValidCappedName(t *testing.T, got string) {
t.Helper()
if len(got) > helmReleaseNameMax {
t.Errorf("expected <= %d bytes, got %d (%q)", helmReleaseNameMax, len(got), got)
}
if got == "" {
t.Errorf("expected non-empty result")
}
if strings.HasPrefix(got, "-") || strings.HasSuffix(got, "-") {
t.Errorf("result %q must not start or end with '-'", got)
}
}

func repeat(b byte, n int) string {
return strings.Repeat(string(b), n)
}