133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
/**
|
|
* 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}`;
|
|
}
|