Tenant naming logic adjustments
All checks were successful
Build and Push / build (push) Successful in 1m22s
All checks were successful
Build and Push / build (push) Successful in 1m22s
This commit is contained in:
132
src/lib/tenant-naming.ts
Normal file
132
src/lib/tenant-naming.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Deterministic tenant-name derivation for PiecedTenant CRs.
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* Every PiecedTenant CR's `metadata.name` becomes part of the tenant
|
||||
* namespace, which the operator builds as `tenant-{name}` (see
|
||||
* `pieced-operator/api/v1alpha1/piecedtenant_types.go::NamespaceName`).
|
||||
* Kubernetes namespace names follow the RFC 1123 DNS *label* spec:
|
||||
* lowercased alphanumeric + hyphens, must start and end with alnum,
|
||||
* and **max 63 characters**.
|
||||
*
|
||||
* That gives us 63 - len("tenant-") = 56 chars to play with for the CR
|
||||
* name itself. Anything longer is rejected at apply time, so we cap
|
||||
* here.
|
||||
*
|
||||
* Format
|
||||
* ------
|
||||
* kind=company → {slug}-{requestIdHex8} e.g. "acme-gmbh-abc12345"
|
||||
* kind=personal → p-{requestIdHex8} e.g. "p-abc12345"
|
||||
*
|
||||
* The 8-hex-char suffix is taken from `tenant_requests.id` (a Postgres
|
||||
* `gen_random_uuid()` value, set at row insert). Two motivations:
|
||||
*
|
||||
* 1. Uniqueness — multiple requests for the same company name no longer
|
||||
* collide (this is what unblocks Slice 3, multi-tenant per org).
|
||||
* 2. Stability — the suffix is known at approval time and never changes,
|
||||
* so the operator and portal agree without coordination. We use the
|
||||
* request UUID rather than the eventual LiteLLM virtual-key UUID
|
||||
* because the latter doesn't exist until the operator runs.
|
||||
*
|
||||
* 8 hex chars = 32 bits of entropy. Collision probability with 100 active
|
||||
* tenants per company prefix is ~1e-6; for our pilot scale that's fine.
|
||||
*
|
||||
* Limits
|
||||
* ------
|
||||
* Suffix is always 8 + 1 (hyphen) = 9 chars. Slug therefore caps at
|
||||
* 56 - 9 = 47 chars, then we strip any trailing hyphens left by the cut.
|
||||
*
|
||||
* Examples
|
||||
* --------
|
||||
* deriveTenantName("company", "Acme GmbH", "abc12345-...") = "acme-gmbh-abc12345"
|
||||
* deriveTenantName("company", "Müller AG", "abc12345-...") = "m-ller-ag-abc12345" (umlaut → "-")
|
||||
* deriveTenantName("company", "!!!", "abc12345-...") = "t-abc12345" (slug empty → "t-")
|
||||
* deriveTenantName("personal", "", "abc12345-...") = "p-abc12345"
|
||||
*/
|
||||
|
||||
export type TenantKind = "company" | "personal";
|
||||
|
||||
const MAX_NAMESPACE_LEN = 63;
|
||||
const NAMESPACE_PREFIX = "tenant-";
|
||||
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length; // 56
|
||||
const SUFFIX_HEX_LEN = 8;
|
||||
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1; // including the joining "-"
|
||||
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN; // 47
|
||||
|
||||
export class InvalidRequestIdError extends Error {
|
||||
constructor(requestId: string) {
|
||||
super(
|
||||
`Cannot derive tenant name: requestId "${requestId}" does not contain ${SUFFIX_HEX_LEN} hex characters`
|
||||
);
|
||||
this.name = "InvalidRequestIdError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an arbitrary string to a DNS-label-safe slug. Non-alnum runs
|
||||
* collapse to a single "-"; leading/trailing hyphens are stripped.
|
||||
*
|
||||
* Note this does not transliterate Unicode — "Müller" becomes "m-ller",
|
||||
* not "mueller". That's deliberate: transliteration introduces locale
|
||||
* dependencies (de-DE vs de-CH vs sv-SE all disagree on ä→a/ä→ae) and
|
||||
* we'd rather have a stable, ugly slug than a pretty one that changes
|
||||
* if we touch the locale config later. Customers see the human-readable
|
||||
* `displayName`, not the slug.
|
||||
*/
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first 8 hex chars of a UUID string. Strips hyphens and
|
||||
* lowercases first so callers can pass either "abc12345-..." or
|
||||
* "ABC12345..." form. Postgres `gen_random_uuid()` already emits the
|
||||
* canonical lowercase-hyphenated form, so this is just defense in depth
|
||||
* against any hand-inserted IDs.
|
||||
*/
|
||||
function requestIdSuffix(requestId: string): string {
|
||||
const hex = requestId.replace(/-/g, "").toLowerCase();
|
||||
if (!/^[0-9a-f]{8}/.test(hex)) {
|
||||
throw new InvalidRequestIdError(requestId);
|
||||
}
|
||||
return hex.slice(0, SUFFIX_HEX_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PiecedTenant CR `metadata.name` for an approved tenant request.
|
||||
*
|
||||
* @param kind "company" for normal customer accounts; "personal"
|
||||
* for individual accounts (Slice 4 — `is_personal=true`).
|
||||
* @param companyName Raw display name from the registration. Ignored when
|
||||
* kind="personal".
|
||||
* @param requestId `tenant_requests.id` (Postgres UUID).
|
||||
* @returns A K8s-safe CR name, ≤ 56 chars, with an 8-hex suffix.
|
||||
*/
|
||||
export function deriveTenantName(
|
||||
kind: TenantKind,
|
||||
companyName: string,
|
||||
requestId: string
|
||||
): string {
|
||||
const suffix = requestIdSuffix(requestId);
|
||||
|
||||
if (kind === "personal") {
|
||||
return `p-${suffix}`;
|
||||
}
|
||||
|
||||
// Company branch: slug-{suffix}, with empty-slug fallback.
|
||||
const rawSlug = slugify(companyName);
|
||||
|
||||
// Cap then re-trim — slicing might leave a dangling hyphen if a non-alnum
|
||||
// run sat right at the boundary (e.g. "acme-foo-bar-..." cut to "acme-foo-").
|
||||
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
|
||||
|
||||
if (!slug) {
|
||||
return `t-${suffix}`;
|
||||
}
|
||||
|
||||
return `${slug}-${suffix}`;
|
||||
}
|
||||
Reference in New Issue
Block a user