feat: harden self-managed reconciliation
This commit is contained in:
+125
-26
@@ -18,12 +18,14 @@ import { type AuditEvent, type AuditPersistence } from "./audit-store";
|
||||
import {
|
||||
managementDependencies,
|
||||
type ManagementDependencies,
|
||||
type OperationExecution,
|
||||
type ResourceIdentity,
|
||||
} from "./management";
|
||||
import type { RollbackCandidate } from "../lib/rollback";
|
||||
import { LABELS } from "../const";
|
||||
import type { WorkspaceAdoptionResult, WorkspaceAdoptionService } from "./app";
|
||||
import { WorkspaceAdoptionError } from "./app";
|
||||
import { validateTrust, type TrustStore } from "./trust-store";
|
||||
import {
|
||||
RESERVED_NAMESPACES,
|
||||
WORKSPACE_PROJECT_LABEL,
|
||||
@@ -164,7 +166,11 @@ export interface LeaseObjects {
|
||||
create(value: V1Lease): Promise<V1Lease>;
|
||||
read(name: string, namespace: string): Promise<V1Lease | undefined>;
|
||||
replace(value: V1Lease): Promise<V1Lease>;
|
||||
delete(name: string, namespace: string): Promise<void>;
|
||||
delete(
|
||||
name: string,
|
||||
namespace: string,
|
||||
expectedResourceVersion?: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/** Wraps a CoordinationV1Api client in the LeaseObjects adapter. */
|
||||
@@ -193,9 +199,17 @@ export function createKubernetesLeaseObjects(
|
||||
body: value,
|
||||
});
|
||||
},
|
||||
async delete(name, namespace) {
|
||||
async delete(name, namespace, expectedResourceVersion) {
|
||||
try {
|
||||
await coordination.deleteNamespacedLease({ name, namespace });
|
||||
await coordination.deleteNamespacedLease({
|
||||
name,
|
||||
namespace,
|
||||
...(expectedResourceVersion && {
|
||||
body: {
|
||||
preconditions: { resourceVersion: expectedResourceVersion },
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return;
|
||||
throw error;
|
||||
@@ -217,8 +231,9 @@ const MAX_LEASE_ACQUIRE_RETRIES = 5;
|
||||
*/
|
||||
function microTimeString(ms: number): string {
|
||||
const iso = new Date(ms).toISOString();
|
||||
const match =
|
||||
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/.exec(iso);
|
||||
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/.exec(
|
||||
iso,
|
||||
);
|
||||
if (!match) return iso;
|
||||
const fraction = (match[2] ?? "").padEnd(6, "0");
|
||||
return `${match[1]}.${fraction}Z`;
|
||||
@@ -254,7 +269,11 @@ export class KubernetesWorkspaceLeaseProvider implements WorkspaceLeaseProvider
|
||||
return digestName("lease", workspaceId);
|
||||
}
|
||||
|
||||
private leaseSpec(workspaceId: string, holder: string, ttlMs: number): V1Lease {
|
||||
private leaseSpec(
|
||||
workspaceId: string,
|
||||
holder: string,
|
||||
ttlMs: number,
|
||||
): V1Lease {
|
||||
return {
|
||||
apiVersion: KUBER_LEASE_API_VERSION,
|
||||
kind: "Lease",
|
||||
@@ -268,7 +287,9 @@ export class KubernetesWorkspaceLeaseProvider implements WorkspaceLeaseProvider
|
||||
acquireTime: microTimeString(
|
||||
this.now(),
|
||||
) as unknown as V1LeaseSpec["acquireTime"],
|
||||
renewTime: microTimeString(this.now()) as unknown as V1LeaseSpec["renewTime"],
|
||||
renewTime: microTimeString(
|
||||
this.now(),
|
||||
) as unknown as V1LeaseSpec["renewTime"],
|
||||
leaseTransitions: 0,
|
||||
},
|
||||
};
|
||||
@@ -328,8 +349,7 @@ export class KubernetesWorkspaceLeaseProvider implements WorkspaceLeaseProvider
|
||||
workspaceId: string,
|
||||
holder: string,
|
||||
): WorkspaceLease {
|
||||
const leaseDurationMs =
|
||||
(lease.spec?.leaseDurationSeconds ?? 30) * 1000;
|
||||
const leaseDurationMs = (lease.spec?.leaseDurationSeconds ?? 30) * 1000;
|
||||
const expiresAt = (
|
||||
Date.parse(String(lease.spec?.renewTime)) + leaseDurationMs
|
||||
).toString();
|
||||
@@ -370,7 +390,16 @@ export class KubernetesWorkspaceLeaseProvider implements WorkspaceLeaseProvider
|
||||
if (!name) return;
|
||||
const current = await this.objects.read(name, this.namespace);
|
||||
if (current?.spec?.holderIdentity !== holder) return;
|
||||
await this.objects.delete(name, this.namespace);
|
||||
const resourceVersion = current.metadata?.resourceVersion;
|
||||
if (!resourceVersion) return;
|
||||
try {
|
||||
await this.objects.delete(name, this.namespace, resourceVersion);
|
||||
} catch (error) {
|
||||
// A replace by a successor between read and delete invalidates the
|
||||
// resourceVersion precondition. Its lease must remain intact.
|
||||
if (isConflict(error) || isNotFound(error)) return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { workspaceId, holder, expiresAt, renew, release };
|
||||
@@ -821,7 +850,7 @@ export class KubernetesWorkspaceAdoptionService implements WorkspaceAdoptionServ
|
||||
export class KubernetesOperationPersistence implements OperationPersistence {
|
||||
constructor(private readonly objects = createKubernetesClients().objects) {}
|
||||
|
||||
async createIdempotent(operation: Operation): Promise<Operation> {
|
||||
async createIdempotent(operation: Operation) {
|
||||
const operationName = digestName(
|
||||
"operation",
|
||||
`${operation.spec.workspaceId}\0${operation.spec.idempotencyKey}`,
|
||||
@@ -841,7 +870,7 @@ export class KubernetesOperationPersistence implements OperationPersistence {
|
||||
deterministic.spec.workspaceId,
|
||||
),
|
||||
);
|
||||
return deterministic;
|
||||
return { operation: deterministic, created: true };
|
||||
} catch (error) {
|
||||
if (!isConflict(error)) throw error;
|
||||
const existing = await this.get(operationName);
|
||||
@@ -849,7 +878,7 @@ export class KubernetesOperationPersistence implements OperationPersistence {
|
||||
throw new OperationConflictError(
|
||||
"Idempotent operation could not be recovered",
|
||||
);
|
||||
return existing;
|
||||
return { operation: existing, created: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,6 +960,44 @@ export class KubernetesAuditPersistence implements AuditPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
export class KubernetesTrustStore implements TrustStore {
|
||||
constructor(private readonly objects = createKubernetesClients().objects) {}
|
||||
private name(project: string, fingerprint: string) {
|
||||
return digestName("trust", `${project}\0${fingerprint}`);
|
||||
}
|
||||
async grant(project: string, fingerprint: string): Promise<void> {
|
||||
validateTrust(project, fingerprint);
|
||||
if (await this.has(project, fingerprint)) return;
|
||||
await createObject(
|
||||
this.objects,
|
||||
stateObject(
|
||||
"ConfigMap",
|
||||
this.name(project, fingerprint),
|
||||
"trust",
|
||||
{ project, fingerprint },
|
||||
project,
|
||||
),
|
||||
);
|
||||
}
|
||||
async list(project: string): Promise<string[]> {
|
||||
return (await list(this.objects, "ConfigMap", "trust", project))
|
||||
.map((item) => parsePayload<{ project: string; fingerprint: string }>(item))
|
||||
.filter((record): record is { project: string; fingerprint: string } =>
|
||||
Boolean(record && record.project === project && /^[a-f0-9]{64}$/.test(record.fingerprint)),
|
||||
)
|
||||
.map((record) => record.fingerprint);
|
||||
}
|
||||
async has(project: string, fingerprint: string): Promise<boolean> {
|
||||
const item = await read(this.objects, "ConfigMap", this.name(project, fingerprint));
|
||||
const record = item && parsePayload<{ project: string; fingerprint: string }>(item);
|
||||
return record?.project === project && record.fingerprint === fingerprint;
|
||||
}
|
||||
async revoke(project: string, fingerprint: string): Promise<boolean> {
|
||||
if (!(await this.has(project, fingerprint))) return false;
|
||||
return deleteObject(this.objects, "ConfigMap", this.name(project, fingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
function resource(identity: ResourceIdentity): KubernetesObject {
|
||||
return {
|
||||
apiVersion: identity.apiVersion,
|
||||
@@ -943,6 +1010,30 @@ 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 {
|
||||
@@ -965,17 +1056,16 @@ export function createKubernetesManagementDependencies(
|
||||
};
|
||||
};
|
||||
const replicaSets = async (project: string, deploymentUid?: string) =>
|
||||
(
|
||||
// Deployment-created ReplicaSets inherit only the pod-template labels
|
||||
// (e.g. app, pod-template-hash), never the Deployment's metadata
|
||||
// managed-by label, so a managed selector here excludes every revision
|
||||
// and rollback reports "no previous release". List namespace-wide and
|
||||
// restrict by ownerReference instead.
|
||||
await apps.listNamespacedReplicaSet({ namespace: project })
|
||||
).items.filter((item) =>
|
||||
item.metadata?.ownerReferences?.some(
|
||||
(owner) => owner.kind === "Deployment" && owner.uid === deploymentUid,
|
||||
),
|
||||
// Deployment-created ReplicaSets inherit only the pod-template labels
|
||||
// (e.g. app, pod-template-hash), never the Deployment's metadata
|
||||
// managed-by label, so a managed selector here excludes every revision
|
||||
// and rollback reports "no previous release". List namespace-wide and
|
||||
// restrict by ownerReference instead.
|
||||
(await apps.listNamespacedReplicaSet({ namespace: project })).items.filter(
|
||||
(item) =>
|
||||
item.metadata?.ownerReferences?.some(
|
||||
(owner) => owner.kind === "Deployment" && owner.uid === deploymentUid,
|
||||
),
|
||||
);
|
||||
const overrides: Partial<ManagementDependencies> = {
|
||||
listDeployments: async (project) =>
|
||||
@@ -1007,9 +1097,16 @@ export function createKubernetesManagementDependencies(
|
||||
},
|
||||
},
|
||||
}),
|
||||
waitForDeployment: async (project, name, timeoutMs = 300_000) => {
|
||||
waitForDeployment: async (
|
||||
project,
|
||||
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,
|
||||
@@ -1023,7 +1120,9 @@ export function createKubernetesManagementDependencies(
|
||||
(deployment.status?.unavailableReplicas ?? 0) === 0
|
||||
)
|
||||
return;
|
||||
await Bun.sleep(2_000);
|
||||
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`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user