Files
kuber/lib/database.ts
T

589 lines
17 KiB
TypeScript

import type { KubernetesObject, V1Secret } from "@kubernetes/client-node";
import { randomUUID } from "node:crypto";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import { LABELS } from "../const";
import { deleteResource, applyResource } from "./apply";
export const DATABASE_RECONCILE_PHASES = [
"namespace precheck", "database dependency", "database resource listing",
"database resource ownership", "claim discovery", "credential preparation",
"role secret lookup", "role secret apply", "cluster lookup",
"managed role preparation", "managed role update", "database preparation",
"database lookup", "database ownership", "database apply", "credential lookup",
"environment assembly", "operation execution",
] as const;
export class DatabaseReconciliationError extends Error {
readonly phase: string;
constructor(phase: string, error: unknown, claim?: PostgresClaim) {
const safePhase: string = DATABASE_RECONCILE_PHASES.some((known) => known === phase)
? phase
: "operation execution";
// Claim values are user-controlled and may themselves be credentials.
// Keep the actionable phase, but never persist or expose claim identifiers.
const context = claim ? " for requested database claim" : "";
const provider =
error && typeof error === "object"
? (error as Record<string, unknown>)
: {};
const body =
provider.body && typeof provider.body === "object"
? (provider.body as Record<string, unknown>)
: {};
const statusCode = [provider.statusCode, provider.code, body.code].find(
(value) =>
typeof value === "number" &&
Number.isInteger(value) &&
value >= 400 &&
value < 600,
);
const status = statusCode === undefined ? "" : ` (HTTP ${statusCode})`;
// Provider messages and response bodies can contain secrets, connection
// URLs or the full request. Match only a fixed vocabulary, never echo them.
const reason = error instanceof Error ? error.message : "";
const knownReason =
/^(forbidden|not found|conflict|permission denied|connection refused|timed out|timeout|unauthorized|unprocessable entity|service unavailable)\b/i.exec(
reason,
);
const providerReason =
typeof body.reason === "string"
? /^(Forbidden|NotFound|AlreadyExists|Conflict|Unauthorized|Invalid|ServiceUnavailable)$/.exec(
body.reason,
)?.[1]
: undefined;
const fallback = safePhase === "claim discovery"
? "Check PostgreSQL claim declarations"
: safePhase === "operation execution" && !status
? "Unexpected failure; check server logs using the operation ID"
: "Kubernetes request failed";
const safeReason = `${knownReason?.[1] ?? providerReason ?? fallback}${status}`;
super(`Database reconciliation failed during ${safePhase}${context}: ${safeReason}`, {
cause: error,
});
this.name = "DatabaseReconciliationError";
this.phase = safePhase;
}
}
export const DATABASE_NAMESPACE = "database";
export const DATABASE_CLUSTER = "postgres";
export const DATABASE_HOST = `c.${DATABASE_NAMESPACE}.svc.cluster.local`;
export const DATABASE_PORT = 5432;
export const REDIS_URL = "redis://redis.database.svc.cluster.local";
export const DATABASE_PROJECT_LABEL = "kuber.dev/project";
export const DATABASE_SERVICE_LABEL = "kuber.dev/service";
type ManagedRole = {
bypassrls: boolean;
connectionLimit: number;
createdb: boolean;
createrole: boolean;
ensure: "present";
inherit: boolean;
login: boolean;
name: string;
passwordSecret: {
name: string;
};
replication: boolean;
superuser: boolean;
};
export type PostgresClaim = {
service: string;
username: string;
database: string;
secretName: string;
};
export type RoleCredentials = {
username: string;
password: string;
};
function toSecretName(username: string) {
return `${DATABASE_CLUSTER}-${username}`;
}
function decodeSecretValue(value: string | undefined): string | undefined {
return value ? Buffer.from(value, "base64").toString("utf8") : undefined;
}
function encodeConnectionComponent(value: string): string {
return encodeURIComponent(value);
}
export function buildDatabaseUrl(
claim: PostgresClaim,
credentials: RoleCredentials,
): string {
return `postgresql://${encodeConnectionComponent(credentials.username)}:${encodeConnectionComponent(credentials.password)}@${DATABASE_HOST}:${DATABASE_PORT}/${encodeConnectionComponent(claim.database)}`;
}
export function buildPostgresEnvironment(
claim: PostgresClaim,
credentials: RoleCredentials,
): Record<string, string> {
return {
DATABASE_URL: buildDatabaseUrl(claim, credentials),
REDIS_URL,
};
}
function parsePostgresVolumeString(
entry: string,
): Omit<PostgresClaim, "service" | "secretName"> | undefined {
if (!entry.startsWith("postgresql:")) return;
const parts = entry.split(":");
if (parts.length !== 2) {
throw new Error(
`Invalid postgres volume ${entry}. Use postgresql:<name> or postgresql:<user>/<database>.`,
);
}
const target = parts[1]?.trim();
if (!target) {
throw new Error(
`Invalid postgres volume ${entry}. Use postgresql:<name> or postgresql:<user>/<database>.`,
);
}
const segments = target.split("/");
if (
segments.length > 2 ||
segments.some((segment) => segment.trim() === "")
) {
throw new Error(
`Invalid postgres volume ${entry}. Use postgresql:<name> or postgresql:<user>/<database>.`,
);
}
const username = segments[0]!;
const database = segments[1] ?? username;
return { username, database };
}
export function isPostgresVolumeEntry(
entry: NonNullable<Service["volumes"]>[number],
): boolean {
return (
typeof entry === "string" && parsePostgresVolumeString(entry) !== undefined
);
}
export function getServicePostgresClaim(
serviceName: string,
service: Service,
): PostgresClaim | undefined {
const claims =
service.volumes?.flatMap((entry) => {
if (typeof entry !== "string") return [];
const claim = parsePostgresVolumeString(entry);
return claim
? [
{
...claim,
service: serviceName,
secretName: toSecretName(claim.username),
} satisfies PostgresClaim,
]
: [];
}) ?? [];
if (claims.length > 1) {
throw new Error(
`Service ${serviceName} declares multiple postgres volumes. Only zero or one postgresql:<...> entry is allowed per service.`,
);
}
return claims[0];
}
export function getComposePostgresClaims(
compose: ComposeSpecification,
): PostgresClaim[] {
const claims = Object.entries(compose.services ?? {}).flatMap(
([serviceName, service]) => {
const claim = getServicePostgresClaim(serviceName, service);
return claim ? [claim] : [];
},
);
const ownersByDatabase = new Map<string, string>();
for (const claim of claims) {
const owner = ownersByDatabase.get(claim.database);
if (owner && owner !== claim.username) {
throw new Error(
`Database ${claim.database} is claimed by both ${owner} and ${claim.username}. A database can only have one owner.`,
);
}
ownersByDatabase.set(claim.database, claim.username);
}
return claims;
}
async function readObject<T>(
resource: KubernetesObject,
): Promise<T | undefined> {
try {
const { objectApi } = await import("./k8s");
return (await objectApi.read(resource as never)) as T;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === 404
) {
return;
}
throw error;
}
}
async function ensureRoleSecret(
claim: PostgresClaim,
): Promise<RoleCredentials> {
let existing: V1Secret | undefined;
try {
existing = await readObject<V1Secret>({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: claim.secretName,
namespace: DATABASE_NAMESPACE,
},
});
} catch (error) {
throw new DatabaseReconciliationError("role secret lookup", error, claim);
}
try {
const username = claim.username;
const password = decodeSecretValue(existing?.data?.password) ?? randomUUID();
await applyResource({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: claim.secretName,
namespace: DATABASE_NAMESPACE,
},
type: existing?.type ?? "Opaque",
stringData: {
username,
password,
},
} satisfies V1Secret);
return { username, password };
} catch (error) {
throw new DatabaseReconciliationError("role secret apply", error, claim);
}
}
function toManagedRole(claim: PostgresClaim): ManagedRole {
return {
bypassrls: false,
connectionLimit: -1,
createdb: false,
createrole: false,
ensure: "present",
inherit: true,
login: true,
name: claim.username,
passwordSecret: {
name: claim.secretName,
},
replication: false,
superuser: false,
};
}
function throwIfAborted(signal?: AbortSignal): void {
if (!signal?.aborted) return;
throw new Error("Workspace operation execution was cancelled");
}
async function reconcileManagedRoles(
claims: PostgresClaim[],
signal?: AbortSignal,
): Promise<void> {
if (claims.length === 0) return;
let cluster: KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } } | undefined;
try {
cluster = await readObject<
KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } }
>({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Cluster",
metadata: {
name: DATABASE_CLUSTER,
namespace: DATABASE_NAMESPACE,
},
});
} catch (error) {
throw new DatabaseReconciliationError("cluster lookup", error, claims[0]);
}
if (!cluster) {
throw new DatabaseReconciliationError(
"cluster lookup",
new Error("Not found"),
claims[0],
);
}
let roles: Map<string, ManagedRole>;
try {
roles = new Map(
(cluster.spec?.managed?.roles ?? []).map((role) => [role.name, role]),
);
for (const claim of claims) {
roles.set(claim.username, toManagedRole(claim));
}
} catch (error) {
throw new DatabaseReconciliationError("managed role preparation", error, claims[0]);
}
try {
throwIfAborted(signal);
await applyResource({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Cluster",
metadata: {
name: DATABASE_CLUSTER,
namespace: DATABASE_NAMESPACE,
},
spec: {
managed: {
roles: [...roles.values()],
},
},
});
} catch (error) {
throw new DatabaseReconciliationError("managed role update", error, claims[0]);
}
}
async function reconcileDatabases(
project: string,
claims: PostgresClaim[],
existingDatabases: Set<string>,
signal?: AbortSignal,
): Promise<void> {
const uniqueDatabases = new Map<string, PostgresClaim>();
try {
for (const claim of claims) {
uniqueDatabases.set(`${claim.database}:${claim.username}`, claim);
}
} catch (error) {
throw new DatabaseReconciliationError("database preparation", error);
}
for (const claim of uniqueDatabases.values()) {
// An existing Database may be shared with other workspaces. Applying even
// an identical object can prune labels owned by the SSA field manager.
if (existingDatabases.has(claim.database)) continue;
try {
throwIfAborted(signal);
const { objectApi } = await import("./k8s");
await objectApi.create({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Database",
metadata: {
name: claim.database,
namespace: DATABASE_NAMESPACE,
labels: {
...LABELS,
[DATABASE_PROJECT_LABEL]: project,
[DATABASE_SERVICE_LABEL]: claim.service,
},
},
spec: {
cluster: {
name: DATABASE_CLUSTER,
},
databaseReclaimPolicy: "retain",
ensure: "present",
name: claim.database,
owner: claim.username,
},
} as KubernetesObject);
} catch (error) {
throw new DatabaseReconciliationError("database apply", error, claim);
}
}
}
async function inspectDatabases(
claims: PostgresClaim[],
signal?: AbortSignal,
): Promise<Set<string>> {
const existing = new Set<string>();
for (const claim of claims) {
if (existing.has(claim.database)) continue;
let database: (KubernetesObject & {
spec?: { owner?: string; cluster?: { name?: string } };
}) | undefined;
try {
throwIfAborted(signal);
database = await readObject({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Database",
metadata: { name: claim.database, namespace: DATABASE_NAMESPACE },
});
} catch (error) {
throw new DatabaseReconciliationError("database lookup", error, claim);
}
if (!database) continue;
if (
database.spec?.owner !== claim.username ||
database.spec?.cluster?.name !== DATABASE_CLUSTER
) {
throw new DatabaseReconciliationError(
"database ownership",
new Error("Existing database does not match the requested role and cluster"),
claim,
);
}
existing.add(claim.database);
}
return existing;
}
export async function reconcilePostgresClaim(
project: string,
claim: PostgresClaim,
signal?: AbortSignal,
): Promise<RoleCredentials> {
try {
throwIfAborted(signal);
} catch (error) {
throw new DatabaseReconciliationError("credential preparation", error, claim);
}
const existing = await inspectDatabases([claim], signal);
const credentials = await ensureRoleSecret(claim);
await reconcileManagedRoles([claim], signal);
await reconcileDatabases(project, [claim], existing, signal);
return credentials;
}
export async function reconcilePostgresClaims(
project: string,
compose: ComposeSpecification,
signal?: AbortSignal,
): Promise<Record<string, Record<string, string>>> {
let claims: PostgresClaim[];
try {
claims = getComposePostgresClaims(compose);
} catch (error) {
throw new DatabaseReconciliationError("claim discovery", error);
}
if (claims.length === 0) return {};
// Validate every requested database before touching any role Secret or the
// shared Cluster; one conflicting claim must leave all roles untouched.
const existing = await inspectDatabases(claims, signal);
const credentialsBySecret = new Map<string, RoleCredentials>();
for (const claim of claims) {
if (credentialsBySecret.has(claim.secretName)) continue;
try {
throwIfAborted(signal);
} catch (error) {
throw new DatabaseReconciliationError("credential preparation", error, claim);
}
credentialsBySecret.set(claim.secretName, await ensureRoleSecret(claim));
}
await reconcileManagedRoles(claims, signal);
await reconcileDatabases(project, claims, existing, signal);
return Object.fromEntries(
claims.map((claim) => {
const credentials = credentialsBySecret.get(claim.secretName);
if (!credentials) {
throw new DatabaseReconciliationError(
"credential lookup",
new Error("Missing credentials"),
claim,
);
}
try {
return [claim.service, buildPostgresEnvironment(claim, credentials)];
} catch (error) {
throw new DatabaseReconciliationError("environment assembly", error, claim);
}
}),
);
}
export async function getRoleCredentials(
username: string,
): Promise<RoleCredentials> {
const secret = await readObject<V1Secret>({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: toSecretName(username),
namespace: DATABASE_NAMESPACE,
},
});
const resolvedUsername = decodeSecretValue(secret?.data?.username);
const password = decodeSecretValue(secret?.data?.password);
if (!resolvedUsername || !password) {
throw new Error(
`Managed role secret ${toSecretName(username)} was not found or is missing credentials.`,
);
}
return {
username: resolvedUsername,
password,
};
}
export async function listManagedDatabaseResources(
project: string,
): Promise<KubernetesObject[]> {
const { objectApi } = await import("./k8s");
const result = await objectApi.list(
"postgresql.cnpg.io/v1",
"Database",
DATABASE_NAMESPACE,
undefined,
undefined,
undefined,
undefined,
`${Object.entries(LABELS)
.map(([key, value]) => `${key}=${value}`)
.join(",")},${DATABASE_PROJECT_LABEL}=${project}`,
);
return result.items.map((item) => ({
...item,
apiVersion: item.apiVersion ?? "postgresql.cnpg.io/v1",
kind: item.kind ?? "Database",
metadata: {
...item.metadata,
namespace: item.metadata?.namespace ?? DATABASE_NAMESPACE,
},
}));
}
export async function deleteManagedDatabases(project: string): Promise<void> {
const resources = await listManagedDatabaseResources(project);
for (const resource of resources) {
await deleteResource(resource);
}
}