feat: release 2.5.1

This commit is contained in:
2026-09-27 10:40:50 +00:00 Unverified
parent 46d97138dc
commit 5f7509a9c5
53 changed files with 2871 additions and 370 deletions
+79 -68
View File
@@ -15,7 +15,8 @@ import {
import { createHash } from "node:crypto";
import { createKubernetesHttpLibrary } from "../lib/k8s-http";
import { sanitizeKubernetesObject } from "../lib/kubernetes-object";
import { type AuditEvent, type AuditPersistence } from "./audit-store";
import { waitForRollout } from "../lib/rollout";
import { MAX_AUDIT_EVENTS, type AuditEvent, type AuditPersistence } from "./audit-store";
import {
managementDependencies,
type ManagementDependencies,
@@ -942,28 +943,75 @@ export class KubernetesOperationPersistence implements OperationPersistence {
export class KubernetesAuditPersistence implements AuditPersistence {
constructor(private readonly objects = createKubernetesClients().objects) {}
async append(event: AuditEvent): Promise<void> {
await createObject(
this.objects,
stateObject(
"ConfigMap",
event.metadata.name,
"audit",
event,
event.spec.workspaceId,
),
private readonly name = "audit-log";
private async readRing(): Promise<DataObject | undefined> {
try {
return (await this.objects.read({
apiVersion: "v1", kind: "ConfigMap",
metadata: { name: this.name, namespace: KUBER_STATE_NAMESPACE },
})) as DataObject;
} catch (error) {
if (isNotFound(error)) return;
throw error;
}
}
private async legacy(): Promise<DataObject[]> {
const result = await this.objects.list(
"v1", "ConfigMap", KUBER_STATE_NAMESPACE, undefined, undefined,
undefined, undefined, `${TYPE_LABEL}=audit`,
);
return result.items.map((item) => ({
apiVersion: "v1", kind: "ConfigMap", ...item,
})) as DataObject[];
}
private async reconcile(add?: AuditEvent): Promise<AuditEvent[]> {
for (let attempt = 0; attempt < 128; attempt++) {
const [ring, legacy] = await Promise.all([this.readRing(), this.legacy()]);
const current = ring && parsePayload<{ kind: string; items: AuditEvent[] }>(ring);
const events = new Map<string, AuditEvent>();
for (const event of [...(current?.items ?? []), ...legacy.map(parsePayload<AuditEvent>).filter((event): event is AuditEvent => event?.kind === "AuditEvent"), ...(add ? [add] : [])]) {
if (event?.kind === "AuditEvent") events.set(event.metadata.uid || event.metadata.name, event);
}
const ordered = [...events.values()].sort((a, b) =>
a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp) ||
a.metadata.name.localeCompare(b.metadata.name),
);
let retained = ordered.slice(-MAX_AUDIT_EVENTS);
while (retained.length && Buffer.byteLength(JSON.stringify({ kind: "AuditLog", items: retained })) > 768 * 1024) retained.shift();
const value = stateObject("ConfigMap", this.name, "audit-log", { kind: "AuditLog", items: retained });
try {
if (ring) {
value.metadata!.resourceVersion = ring.metadata?.resourceVersion;
await this.objects.replace(value);
} else {
await this.objects.create(value);
}
} catch (error) {
if (isConflict(error)) continue;
throw error;
}
// Never remove legacy records until the merged ring has been committed.
for (const item of legacy.slice(0, 100)) {
const name = item.metadata?.name;
if (name) await deleteObject(this.objects, "ConfigMap", name);
}
return retained;
}
throw new Error("Audit log was concurrently modified; retry limit reached");
}
async append(event: AuditEvent): Promise<void> {
await this.reconcile(event);
}
async list(workspaceId?: string): Promise<AuditEvent[]> {
return (await list(this.objects, "ConfigMap", "audit", workspaceId))
.map((item) => parsePayload<AuditEvent>(item))
.filter((item): item is AuditEvent => item?.kind === "AuditEvent")
.sort((a, b) =>
a.metadata.creationTimestamp.localeCompare(
b.metadata.creationTimestamp,
),
);
const events = await this.reconcile();
return events.filter((event) =>
workspaceId ? event.spec.workspaceId === workspaceId : true,
);
}
}
@@ -1032,34 +1080,10 @@ function resource(identity: ResourceIdentity): KubernetesObject {
};
}
async function sleepUntilExecutionCancelled(
delayMs: number,
execution?: OperationExecution,
): Promise<void> {
const signal = execution?.signal;
if (!signal) {
await Bun.sleep(delayMs);
return;
}
if (signal.aborted)
throw new Error("Workspace operation execution was cancelled");
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", cancelSleep);
resolve();
}, delayMs);
const cancelSleep = () => {
clearTimeout(timer);
reject(new Error("Workspace operation execution was cancelled"));
};
signal.addEventListener("abort", cancelSleep, { once: true });
});
}
export function createKubernetesManagementDependencies(
clients = createKubernetesClients(),
): ManagementDependencies {
const { objects, apps } = clients;
const { objects, apps, core } = clients;
const revision = (replicaSet: V1ReplicaSet): number | undefined => {
const value = Number(
replicaSet.metadata?.annotations?.["deployment.kubernetes.io/revision"],
@@ -1171,30 +1195,17 @@ export function createKubernetesManagementDependencies(
name,
timeoutMs = 300_000,
execution?: OperationExecution,
) => {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (execution?.signal?.aborted)
throw new Error("Workspace operation execution was cancelled");
const deployment = await apps.readNamespacedDeployment({
namespace: project,
name,
});
const desired = deployment.spec?.replicas ?? 1;
if (
(deployment.status?.observedGeneration ?? 0) >=
(deployment.metadata?.generation ?? 0) &&
(deployment.status?.updatedReplicas ?? 0) === desired &&
(deployment.status?.availableReplicas ?? 0) === desired &&
(deployment.status?.unavailableReplicas ?? 0) === 0
)
return;
if (execution?.signal?.aborted)
throw new Error("Workspace operation execution was cancelled");
await sleepUntilExecutionCancelled(2_000, execution);
}
throw new Error(`Timed out waiting for deployment ${name} rollout`);
},
) =>
waitForRollout(
name,
Date.now() + timeoutMs,
() => apps.readNamespacedDeployment({ namespace: project, name }),
async () =>
(await apps.listNamespacedReplicaSet({ namespace: project })).items,
async () =>
(await core.listNamespacedPod({ namespace: project })).items,
execution?.signal,
),
planRollback: async (project, names) => {
const deployments = await overrides.listDeployments!(project);
const selected = names