mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-01 23:21:42 +00:00
* refactor(deadcode): trim auto-reply and CLI exports * refactor(deadcode): trim cron and task exports * refactor(deadcode): trim fleet and process exports * test(deadcode): exercise live task and process seams * test(fleet): cover stream redaction through owner module * refactor(security): trim dead internal exports * refactor(secrets): trim dead internal exports * refactor(deadcode): trim remaining src exports * refactor(deadcode): remove test-only runtime exports * refactor(deadcode): trim pairing test exports * refactor(deadcode): reconcile refreshed baseline * test(auto-reply): deduplicate queue state imports
688 lines
22 KiB
TypeScript
688 lines
22 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import JSON5 from "json5";
|
|
import { FsSafeError, root as fsSafeRoot } from "../infra/fs-safe.js";
|
|
import { replaceFileAtomic } from "../infra/replace-file.js";
|
|
import { isRecord } from "../utils.js";
|
|
import {
|
|
buildCellEnvironment,
|
|
cellAuthSecretDir,
|
|
cellNetworkName,
|
|
cellOwnerId,
|
|
FLEET_ATTEMPT_LABEL,
|
|
FLEET_DISK_LIMIT_LABEL,
|
|
FLEET_ENV_KEYS_LABEL,
|
|
FLEET_OWNER_LABEL,
|
|
FLEET_TENANT_LABEL,
|
|
parseEnvAssignments,
|
|
validateTenantId,
|
|
type CellContainerProfile,
|
|
type FleetContainerRuntimeName,
|
|
} from "./cell-profile.js";
|
|
import type {
|
|
FleetContainerInspectResult,
|
|
FleetContainerRuntime,
|
|
FleetNetworkInspectResult,
|
|
} from "./containers.runtime.js";
|
|
import {
|
|
acquireFleetCellOperation,
|
|
getFleetCell,
|
|
type FleetCellOperationName,
|
|
type FleetCellRecord,
|
|
} from "./registry.js";
|
|
|
|
const CELL_CONFIG_FILENAME = "openclaw.json";
|
|
const HEALTH_TIMEOUT_MS = 1_000;
|
|
const CELL_CONFIG_MAX_BYTES = 4 * 1024 * 1024;
|
|
const FLEET_OPERATION_HEARTBEAT_MS = 60_000;
|
|
|
|
type FleetHealthResult =
|
|
| { status: "ok"; url: string; httpStatus: number }
|
|
| { status: "failed"; url: string; error: string; httpStatus?: number }
|
|
| { status: "skipped"; url: string; reason: string };
|
|
|
|
function requiredRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`${label} must be an object.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (value === undefined) {
|
|
return {};
|
|
}
|
|
return requiredRecord(value, label);
|
|
}
|
|
|
|
function readAllowedOrigins(value: unknown): string[] {
|
|
if (value === undefined) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(value) || !value.every((origin) => typeof origin === "string")) {
|
|
throw new Error("gateway.controlUi.allowedOrigins must be an array of strings.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function ensurePrivateDirectory(dir: string): Promise<void> {
|
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
const stat = await fs.lstat(dir);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to use non-directory fleet data path: ${dir}`);
|
|
}
|
|
await fs.chmod(dir, 0o700);
|
|
}
|
|
|
|
export async function prepareCellDirectories(
|
|
record: FleetCellRecord,
|
|
authSecretDir: string,
|
|
owner?: { uid: number; gid: number },
|
|
): Promise<void> {
|
|
await Promise.all([
|
|
ensurePrivateDirectory(record.dataDir),
|
|
ensurePrivateDirectory(authSecretDir),
|
|
]);
|
|
if (owner) {
|
|
await Promise.all([
|
|
fs.chown(record.dataDir, owner.uid, owner.gid),
|
|
fs.chown(authSecretDir, owner.uid, owner.gid),
|
|
]);
|
|
}
|
|
}
|
|
|
|
export async function prepareCellConfig(
|
|
record: FleetCellRecord,
|
|
owner?: { uid: number; gid: number },
|
|
): Promise<void> {
|
|
const configPath = path.join(record.dataDir, CELL_CONFIG_FILENAME);
|
|
let rootConfig: Record<string, unknown>;
|
|
const cellRoot = await fsSafeRoot(record.dataDir, {
|
|
hardlinks: "reject",
|
|
maxBytes: CELL_CONFIG_MAX_BYTES,
|
|
nonBlockingRead: true,
|
|
symlinks: "reject",
|
|
});
|
|
try {
|
|
const read = await cellRoot.read(CELL_CONFIG_FILENAME);
|
|
rootConfig = requiredRecord(JSON5.parse(read.buffer.toString("utf8")), "Cell config");
|
|
} catch (error) {
|
|
if (error instanceof FsSafeError && error.code === "not-found") {
|
|
rootConfig = {};
|
|
} else if (error instanceof FsSafeError) {
|
|
throw new Error(`Refusing to read unsafe cell config: ${configPath}`, { cause: error });
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const gateway = optionalRecord(rootConfig.gateway, "gateway");
|
|
const auth = optionalRecord(gateway.auth, "gateway.auth");
|
|
const controlUi = optionalRecord(gateway.controlUi, "gateway.controlUi");
|
|
const nextAuth: Record<string, unknown> = { ...auth, mode: "token" };
|
|
delete nextAuth.token;
|
|
const origins = new Set(readAllowedOrigins(controlUi.allowedOrigins));
|
|
origins.add(`http://localhost:${record.hostPort}`);
|
|
origins.add(`http://127.0.0.1:${record.hostPort}`);
|
|
|
|
const nextConfig = {
|
|
...rootConfig,
|
|
gateway: {
|
|
...gateway,
|
|
mode: "local",
|
|
bind: "lan",
|
|
auth: nextAuth,
|
|
controlUi: {
|
|
...controlUi,
|
|
allowedOrigins: [...origins],
|
|
},
|
|
},
|
|
};
|
|
await replaceFileAtomic({
|
|
filePath: configPath,
|
|
content: `${JSON.stringify(nextConfig, null, 2)}\n`,
|
|
dirMode: 0o700,
|
|
mode: 0o600,
|
|
tempPrefix: CELL_CONFIG_FILENAME,
|
|
copyFallbackOnPermissionError: true,
|
|
});
|
|
if (owner) {
|
|
await fs.chown(configPath, owner.uid, owner.gid);
|
|
}
|
|
}
|
|
|
|
export type HostIdentity = { uid: number; gid: number };
|
|
|
|
export function readHostIdentity(
|
|
getuid: () => number | undefined,
|
|
getgid: () => number | undefined,
|
|
): HostIdentity | undefined {
|
|
const uid = getuid();
|
|
const gid = getgid();
|
|
if (uid === undefined || gid === undefined) {
|
|
return undefined;
|
|
}
|
|
if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) {
|
|
throw new Error("Host uid and gid must be non-negative integers.");
|
|
}
|
|
return { uid, gid };
|
|
}
|
|
|
|
export async function resolveContainerUser(params: {
|
|
runtime: FleetContainerRuntimeName;
|
|
containers: FleetContainerRuntime;
|
|
hostIdentity: HostIdentity | undefined;
|
|
user?: string;
|
|
}): Promise<CellContainerProfile["containerUser"]> {
|
|
const match = params.user?.match(/^(\d+):(\d+)$/u);
|
|
if (match) {
|
|
const uid = Number(match[1]);
|
|
const gid = Number(match[2]);
|
|
return params.runtime === "podman"
|
|
? { mode: "podman-keep-id", uid, gid }
|
|
: { mode: "numeric", uid, gid };
|
|
}
|
|
if (!params.hostIdentity) {
|
|
return undefined;
|
|
}
|
|
if (params.runtime === "podman") {
|
|
return params.hostIdentity.uid === 0
|
|
? undefined
|
|
: { mode: "podman-keep-id", ...params.hostIdentity };
|
|
}
|
|
if (await params.containers.isDockerRootless()) {
|
|
// Root in a rootless Docker user namespace maps to the invoking host user, not host root.
|
|
return { mode: "numeric", uid: 0, gid: 0 };
|
|
}
|
|
return params.hostIdentity.uid === 0 ? undefined : { mode: "numeric", ...params.hostIdentity };
|
|
}
|
|
|
|
export async function detectHostSelinux(): Promise<boolean> {
|
|
if (process.platform !== "linux") {
|
|
return false;
|
|
}
|
|
try {
|
|
await fs.access("/sys/fs/selinux");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function inspectionState(
|
|
record: FleetCellRecord,
|
|
inspection: FleetContainerInspectResult,
|
|
): string {
|
|
if (inspection.kind !== "ok") {
|
|
return inspection.state;
|
|
}
|
|
return inspection.labels[FLEET_TENANT_LABEL] === record.tenantId &&
|
|
inspection.labels[FLEET_OWNER_LABEL] === cellOwnerId(record.dataDir)
|
|
? inspection.state
|
|
: "unknown";
|
|
}
|
|
|
|
export function assertManagedInspection(
|
|
record: FleetCellRecord,
|
|
inspection: FleetContainerInspectResult,
|
|
): Extract<FleetContainerInspectResult, { kind: "ok" }> {
|
|
if (inspection.kind === "missing") {
|
|
throw new Error(`Fleet container is missing for tenant ${record.tenantId}.`);
|
|
}
|
|
if (inspection.kind === "unavailable") {
|
|
throw new Error(
|
|
`Cannot inspect ${record.runtime} container for tenant ${record.tenantId}: ${inspection.error}`,
|
|
);
|
|
}
|
|
if (
|
|
inspection.labels[FLEET_TENANT_LABEL] !== record.tenantId ||
|
|
inspection.labels[FLEET_OWNER_LABEL] !== cellOwnerId(record.dataDir)
|
|
) {
|
|
throw new Error(
|
|
`Refusing to manage ${record.containerName}: fleet ownership labels do not match tenant ${record.tenantId}.`,
|
|
);
|
|
}
|
|
return inspection;
|
|
}
|
|
|
|
export async function probeCellHealth(params: {
|
|
port: number;
|
|
fetchImpl: typeof fetch;
|
|
}): Promise<FleetHealthResult> {
|
|
const url = `http://127.0.0.1:${params.port}/healthz`;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
let response: Response | undefined;
|
|
try {
|
|
response = await params.fetchImpl(url, {
|
|
method: "GET",
|
|
redirect: "manual",
|
|
signal: controller.signal,
|
|
});
|
|
if (response.ok) {
|
|
return { status: "ok", url, httpStatus: response.status };
|
|
}
|
|
return {
|
|
status: "failed",
|
|
url,
|
|
httpStatus: response.status,
|
|
error: `HTTP ${response.status}`,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: "failed",
|
|
url,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
await response?.body?.cancel().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function resolvePurgeTarget(
|
|
rootDir: string,
|
|
targetDir: string,
|
|
tenantId: string,
|
|
): Promise<string | undefined> {
|
|
const expectedTarget = path.resolve(rootDir, tenantId);
|
|
if (path.resolve(targetDir) !== expectedTarget) {
|
|
throw new Error(`Refusing to purge data outside its fleet-owned directory: ${targetDir}`);
|
|
}
|
|
let targetStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
try {
|
|
targetStat = await fs.lstat(expectedTarget);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return undefined;
|
|
}
|
|
throw error;
|
|
}
|
|
if (targetStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to purge a symlinked fleet tenant directory: ${targetDir}`);
|
|
}
|
|
const root = await fs.realpath(rootDir);
|
|
const target = await fs.realpath(targetDir);
|
|
const relative = path.relative(root, target);
|
|
// Require the exact real tenant leaf; an in-root sibling symlink could otherwise delete another cell.
|
|
if (
|
|
target !== path.join(root, tenantId) ||
|
|
!relative ||
|
|
relative === ".." ||
|
|
relative.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relative)
|
|
) {
|
|
throw new Error(`Refusing to purge data outside its fleet-owned directory: ${targetDir}`);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export function requireCell(env: NodeJS.ProcessEnv, tenant: string): FleetCellRecord {
|
|
const tenantId = validateTenantId(tenant);
|
|
const record = getFleetCell(env, tenantId);
|
|
if (!record) {
|
|
throw new Error(`Fleet cell not found: ${tenantId}`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
export function assertCurrentReservation(env: NodeJS.ProcessEnv, expected: FleetCellRecord): void {
|
|
const current = getFleetCell(env, expected.tenantId);
|
|
if (
|
|
!current ||
|
|
current.createdAtMs !== expected.createdAtMs ||
|
|
current.image !== expected.image ||
|
|
current.runtime !== expected.runtime ||
|
|
current.hostPort !== expected.hostPort ||
|
|
current.containerName !== expected.containerName ||
|
|
current.dataDir !== expected.dataDir
|
|
) {
|
|
throw new Error(`Fleet create reservation changed while provisioning ${expected.tenantId}.`);
|
|
}
|
|
}
|
|
|
|
function requirePositiveResource(
|
|
value: string,
|
|
label: string,
|
|
context: "upgrade" | "restore" = "upgrade",
|
|
): string {
|
|
const parsed = Number(value);
|
|
if (!value.trim() || !Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new Error(`Cannot ${context} cell: inspected ${label} limit is missing or invalid.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function requireInspectedGatewayToken(
|
|
inspection: Extract<FleetContainerInspectResult, { kind: "ok" }>,
|
|
context: "upgrade" | "restore",
|
|
): string {
|
|
const gatewayCredential = inspection.environment.OPENCLAW_GATEWAY_TOKEN;
|
|
if (!gatewayCredential) {
|
|
throw new Error(`Cannot ${context} cell: existing container has no Gateway token environment.`);
|
|
}
|
|
return gatewayCredential;
|
|
}
|
|
|
|
export function requireInspectedAttemptId(
|
|
inspection: Extract<FleetContainerInspectResult, { kind: "ok" }>,
|
|
context: "upgrade" | "restore",
|
|
): string {
|
|
const attemptId = inspection.labels[FLEET_ATTEMPT_LABEL];
|
|
if (!attemptId || !/^[a-f0-9]{32}$/u.test(attemptId)) {
|
|
throw new Error(`Cannot ${context} cell: container attempt label is missing or invalid.`);
|
|
}
|
|
return attemptId;
|
|
}
|
|
|
|
function requirePidsLimit(
|
|
value: number | undefined,
|
|
context: "upgrade" | "restore" = "upgrade",
|
|
): number {
|
|
if (value === undefined || !Number.isSafeInteger(value) || value < 1) {
|
|
throw new Error(`Cannot ${context} cell: inspected PID limit is missing or invalid.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function rebuildInspectedEnvironment(
|
|
environment: Readonly<Record<string, string>>,
|
|
labels: Readonly<Record<string, string>>,
|
|
token: string,
|
|
context: "upgrade" | "restore" = "upgrade",
|
|
): Record<string, string> {
|
|
const encodedKeys = labels[FLEET_ENV_KEYS_LABEL];
|
|
if (encodedKeys === undefined) {
|
|
throw new Error(`Cannot ${context} cell: user environment provenance label is missing.`);
|
|
}
|
|
const keys = encodedKeys ? encodedKeys.split(",") : [];
|
|
if (new Set(keys).size !== keys.length || keys.toSorted().join(",") !== encodedKeys) {
|
|
throw new Error(`Cannot ${context} cell: user environment provenance label is invalid.`);
|
|
}
|
|
const assignments = keys.map((key) => {
|
|
const value = environment[key];
|
|
if (value === undefined) {
|
|
throw new Error(`Cannot ${context} cell: inspected environment is missing ${key}.`);
|
|
}
|
|
return `${key}=${value}`;
|
|
});
|
|
return buildCellEnvironment(token, parseEnvAssignments(assignments));
|
|
}
|
|
|
|
export function buildProfileBaseFromInspection(params: {
|
|
record: FleetCellRecord;
|
|
stateDir: string;
|
|
inspection: Extract<FleetContainerInspectResult, { kind: "ok" }>;
|
|
containerUser: CellContainerProfile["containerUser"];
|
|
selinuxRelabel: boolean;
|
|
token: string;
|
|
context: "upgrade" | "restore";
|
|
}): Omit<CellContainerProfile, "image" | "attemptId"> {
|
|
return {
|
|
tenantId: params.record.tenantId,
|
|
containerName: params.record.containerName,
|
|
networkName: cellNetworkName(params.record.tenantId),
|
|
runtime: params.record.runtime,
|
|
hostPort: params.record.hostPort,
|
|
dataDir: params.record.dataDir,
|
|
authSecretDir: cellAuthSecretDir(params.stateDir, params.record.tenantId),
|
|
ownerId: cellOwnerId(params.record.dataDir),
|
|
memory: requirePositiveResource(params.inspection.memory, "memory", params.context),
|
|
cpus: requirePositiveResource(params.inspection.cpus, "CPU", params.context),
|
|
pidsLimit: requirePidsLimit(params.inspection.pidsLimit, params.context),
|
|
// Replay the disk limit from the fleet-owned label, not HostConfig.StorageOpt:
|
|
// Podman's inspect schema has no StorageOpt field (verified live), so relying
|
|
// on it would silently drop a Podman cell's quota on upgrade/restore.
|
|
...(params.inspection.labels[FLEET_DISK_LIMIT_LABEL] !== undefined
|
|
? { diskSize: params.inspection.labels[FLEET_DISK_LIMIT_LABEL] }
|
|
: {}),
|
|
environment: rebuildInspectedEnvironment(
|
|
params.inspection.environment,
|
|
params.inspection.labels,
|
|
params.token,
|
|
params.context,
|
|
),
|
|
...(params.containerUser ? { containerUser: params.containerUser } : {}),
|
|
selinuxRelabel: params.selinuxRelabel,
|
|
};
|
|
}
|
|
|
|
export async function verifyReplacementHealthy(params: {
|
|
containers: FleetContainerRuntime;
|
|
record: FleetCellRecord;
|
|
attemptId: string;
|
|
fetchImpl: typeof fetch;
|
|
now: () => number;
|
|
sleep: (ms: number) => Promise<void>;
|
|
checkpoint: () => void;
|
|
timeoutMs: number;
|
|
pollMs: number;
|
|
context: "upgrade" | "restore" | "create";
|
|
}): Promise<void> {
|
|
const deadline = params.now() + params.timeoutMs;
|
|
for (;;) {
|
|
const replacement = await params.containers.inspect(
|
|
params.record.runtime,
|
|
params.record.containerName,
|
|
);
|
|
if (
|
|
replacement.kind !== "ok" ||
|
|
replacement.labels[FLEET_ATTEMPT_LABEL] !== params.attemptId ||
|
|
!replacement.running
|
|
) {
|
|
throw new Error(
|
|
replacement.kind === "ok"
|
|
? `Replacement cell container is not running after ${params.context}.`
|
|
: `Replacement cell container could not be verified after ${params.context}.`,
|
|
);
|
|
}
|
|
const health = await probeCellHealth({
|
|
port: params.record.hostPort,
|
|
fetchImpl: params.fetchImpl,
|
|
});
|
|
if (health.status === "ok") {
|
|
return;
|
|
}
|
|
if (params.now() >= deadline) {
|
|
throw new Error(`Replacement cell container did not become healthy after ${params.context}.`);
|
|
}
|
|
params.checkpoint();
|
|
await params.sleep(params.pollMs);
|
|
}
|
|
}
|
|
|
|
export async function cleanupFailedCreateContainer(
|
|
record: FleetCellRecord,
|
|
containers: FleetContainerRuntime,
|
|
attemptId: string,
|
|
checkpoint: () => void,
|
|
): Promise<boolean> {
|
|
const inspection = await containers.inspect(record.runtime, record.containerName);
|
|
if (inspection.kind === "missing") {
|
|
return true;
|
|
}
|
|
if (inspection.kind === "unavailable") {
|
|
return false;
|
|
}
|
|
const tenantLabel = inspection.labels[FLEET_TENANT_LABEL];
|
|
const ownerLabel = inspection.labels[FLEET_OWNER_LABEL];
|
|
// Fleet always labels what it creates, so a container without fleet labels (or with
|
|
// another owner's labels) is foreign: leave it untouched but release the reservation,
|
|
// otherwise a name collision strands a tenant no fleet command can recover.
|
|
if (tenantLabel !== record.tenantId || ownerLabel !== cellOwnerId(record.dataDir)) {
|
|
return true;
|
|
}
|
|
if (inspection.labels[FLEET_ATTEMPT_LABEL] !== attemptId) {
|
|
return false;
|
|
}
|
|
checkpoint();
|
|
await containers.remove(record.runtime, record.containerName, true);
|
|
return (await containers.inspect(record.runtime, record.containerName)).kind === "missing";
|
|
}
|
|
|
|
export async function cleanupFailedCreateNetwork(
|
|
record: FleetCellRecord,
|
|
containers: FleetContainerRuntime,
|
|
attemptId: string,
|
|
checkpoint: () => void,
|
|
): Promise<boolean> {
|
|
const networkName = cellNetworkName(record.tenantId);
|
|
const inspection = await containers.inspectNetwork(record.runtime, networkName);
|
|
if (inspection.kind === "missing") {
|
|
return true;
|
|
}
|
|
if (inspection.kind === "unavailable") {
|
|
return false;
|
|
}
|
|
const tenantLabel = inspection.labels[FLEET_TENANT_LABEL];
|
|
const ownerLabel = inspection.labels[FLEET_OWNER_LABEL];
|
|
// Same foreign-resource rule as cleanupFailedCreateContainer: unlabeled or
|
|
// other-owner networks are never fleet's to delete, but must not pin the reservation.
|
|
if (tenantLabel !== record.tenantId || ownerLabel !== cellOwnerId(record.dataDir)) {
|
|
return true;
|
|
}
|
|
if (
|
|
inspection.labels[FLEET_ATTEMPT_LABEL] !== attemptId ||
|
|
inspection.attachedContainers.length > 0
|
|
) {
|
|
return false;
|
|
}
|
|
checkpoint();
|
|
await containers.removeNetwork(record.runtime, networkName);
|
|
return (await containers.inspectNetwork(record.runtime, networkName)).kind === "missing";
|
|
}
|
|
|
|
function inspectionHasFleetOwner(
|
|
record: FleetCellRecord,
|
|
inspection: Extract<FleetContainerInspectResult, { kind: "ok" }>,
|
|
): boolean {
|
|
return (
|
|
inspection.labels[FLEET_TENANT_LABEL] === record.tenantId &&
|
|
inspection.labels[FLEET_OWNER_LABEL] === cellOwnerId(record.dataDir)
|
|
);
|
|
}
|
|
|
|
export function assertManagedNetwork(
|
|
record: FleetCellRecord,
|
|
inspection: FleetNetworkInspectResult,
|
|
): Extract<FleetNetworkInspectResult, { kind: "ok" }> {
|
|
if (inspection.kind === "missing") {
|
|
throw new Error(`Fleet network is missing for tenant ${record.tenantId}.`);
|
|
}
|
|
if (inspection.kind === "unavailable") {
|
|
throw new Error(
|
|
`Cannot inspect ${record.runtime} network for tenant ${record.tenantId}: ${inspection.error}`,
|
|
);
|
|
}
|
|
if (
|
|
inspection.labels[FLEET_TENANT_LABEL] !== record.tenantId ||
|
|
inspection.labels[FLEET_OWNER_LABEL] !== cellOwnerId(record.dataDir)
|
|
) {
|
|
throw new Error(
|
|
`Refusing to manage ${cellNetworkName(record.tenantId)}: fleet ownership labels do not match tenant ${record.tenantId}.`,
|
|
);
|
|
}
|
|
const unexpectedAttachments = inspection.attachedContainers.filter(
|
|
(container) => container.name !== record.containerName,
|
|
);
|
|
if (unexpectedAttachments.length > 0 || inspection.attachedContainers.length > 1) {
|
|
throw new Error(
|
|
`Refusing to manage ${cellNetworkName(record.tenantId)}: unexpected containers are attached.`,
|
|
);
|
|
}
|
|
return inspection;
|
|
}
|
|
|
|
export async function restorePreviousCell(params: {
|
|
record: FleetCellRecord;
|
|
containers: FleetContainerRuntime;
|
|
oldProfile: CellContainerProfile;
|
|
previousAttemptId: string;
|
|
nextAttemptId: string;
|
|
wasRunning: boolean;
|
|
checkpoint: () => void;
|
|
}): Promise<void> {
|
|
const current = await params.containers.inspect(
|
|
params.record.runtime,
|
|
params.record.containerName,
|
|
);
|
|
if (current.kind === "unavailable") {
|
|
throw new Error(current.error);
|
|
}
|
|
if (current.kind === "ok") {
|
|
if (!inspectionHasFleetOwner(params.record, current)) {
|
|
throw new Error("container ownership changed during upgrade recovery");
|
|
}
|
|
const currentAttemptId = current.labels[FLEET_ATTEMPT_LABEL];
|
|
if (currentAttemptId === params.previousAttemptId) {
|
|
if (current.running !== params.wasRunning) {
|
|
params.checkpoint();
|
|
await params.containers[current.running ? "stop" : "start"](
|
|
params.record.runtime,
|
|
params.record.containerName,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (currentAttemptId !== params.nextAttemptId) {
|
|
throw new Error("container generation changed during upgrade recovery");
|
|
}
|
|
params.checkpoint();
|
|
await params.containers.remove(params.record.runtime, params.record.containerName, true);
|
|
}
|
|
params.checkpoint();
|
|
await params.containers.run(params.oldProfile, params.wasRunning);
|
|
}
|
|
|
|
export async function withFleetCellOperation<T>(params: {
|
|
env: NodeJS.ProcessEnv;
|
|
tenantId: string;
|
|
operationName: FleetCellOperationName;
|
|
operation: (checkpoint: () => void) => Promise<T>;
|
|
}): Promise<T> {
|
|
const lease = acquireFleetCellOperation({
|
|
env: params.env,
|
|
tenantId: params.tenantId,
|
|
operation: params.operationName,
|
|
});
|
|
let heartbeatError: unknown;
|
|
const checkpoint = () => {
|
|
try {
|
|
lease.heartbeat();
|
|
heartbeatError = undefined;
|
|
} catch (error) {
|
|
heartbeatError = error;
|
|
throw error;
|
|
}
|
|
};
|
|
const heartbeat = setInterval(() => {
|
|
try {
|
|
lease.heartbeat();
|
|
heartbeatError = undefined;
|
|
} catch (error) {
|
|
heartbeatError = error;
|
|
}
|
|
}, FLEET_OPERATION_HEARTBEAT_MS);
|
|
heartbeat.unref();
|
|
let result: T;
|
|
try {
|
|
result = await params.operation(checkpoint);
|
|
if (heartbeatError) {
|
|
checkpoint();
|
|
} else {
|
|
lease.heartbeat();
|
|
}
|
|
} catch (error) {
|
|
clearInterval(heartbeat);
|
|
try {
|
|
lease.release();
|
|
} catch {
|
|
// Preserve the operation or fencing error; a release failure is secondary.
|
|
}
|
|
throw error;
|
|
}
|
|
clearInterval(heartbeat);
|
|
lease.release();
|
|
return result;
|
|
}
|