594 lines
17 KiB
TypeScript
594 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ConfigService } from "@nestjs/config";
|
|
import type { PrismaService } from "../prisma/prisma.service";
|
|
import type { CryptoService } from "../crypto/crypto.service";
|
|
|
|
interface MockUserContainerRecord {
|
|
id: string;
|
|
userId: string;
|
|
containerId: string | null;
|
|
containerName: string;
|
|
gatewayPort: number | null;
|
|
gatewayToken: string;
|
|
status: string;
|
|
lastActiveAt: Date | null;
|
|
idleTimeoutMin: number;
|
|
config: Record<string, unknown>;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
const dockerMock = vi.hoisted(() => {
|
|
interface MockDockerContainerState {
|
|
id: string;
|
|
name: string;
|
|
running: boolean;
|
|
port: number;
|
|
}
|
|
|
|
const containers = new Map<string, MockDockerContainerState>();
|
|
const handles = new Map<
|
|
string,
|
|
{
|
|
inspect: ReturnType<typeof vi.fn>;
|
|
start: ReturnType<typeof vi.fn>;
|
|
stop: ReturnType<typeof vi.fn>;
|
|
}
|
|
>();
|
|
|
|
const ensureHandle = (id: string) => {
|
|
const existing = handles.get(id);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const handle = {
|
|
inspect: vi.fn(async () => {
|
|
const container = containers.get(id);
|
|
if (!container) {
|
|
throw { statusCode: 404 };
|
|
}
|
|
|
|
return {
|
|
Id: container.id,
|
|
State: {
|
|
Running: container.running,
|
|
},
|
|
NetworkSettings: {
|
|
Ports: {
|
|
"18789/tcp": [{ HostPort: String(container.port) }],
|
|
},
|
|
},
|
|
};
|
|
}),
|
|
start: vi.fn(async () => {
|
|
const container = containers.get(id);
|
|
if (!container) {
|
|
throw { statusCode: 404 };
|
|
}
|
|
container.running = true;
|
|
}),
|
|
stop: vi.fn(async () => {
|
|
const container = containers.get(id);
|
|
if (!container) {
|
|
throw { statusCode: 404 };
|
|
}
|
|
container.running = false;
|
|
}),
|
|
};
|
|
|
|
handles.set(id, handle);
|
|
return handle;
|
|
};
|
|
|
|
const listContainers = vi.fn(
|
|
async (options?: { all?: boolean; filters?: { name?: string[] } }) => {
|
|
const nameFilter = options?.filters?.name?.[0];
|
|
return [...containers.values()]
|
|
.filter((container) => (nameFilter ? container.name.includes(nameFilter) : true))
|
|
.map((container) => ({
|
|
Id: container.id,
|
|
Names: [`/${container.name}`],
|
|
}));
|
|
}
|
|
);
|
|
|
|
const getContainer = vi.fn((id: string) => ensureHandle(id));
|
|
|
|
const createContainer = vi.fn(
|
|
async (options: {
|
|
name?: string;
|
|
HostConfig?: { PortBindings?: Record<string, Array<{ HostPort?: string }>> };
|
|
}) => {
|
|
const id = `ctr-${containers.size + 1}`;
|
|
const name = options.name ?? id;
|
|
const hostPort = options.HostConfig?.PortBindings?.["18789/tcp"]?.[0]?.HostPort;
|
|
const port = hostPort ? Number.parseInt(hostPort, 10) : 0;
|
|
|
|
containers.set(id, {
|
|
id,
|
|
name,
|
|
running: false,
|
|
port,
|
|
});
|
|
|
|
return ensureHandle(id);
|
|
}
|
|
);
|
|
|
|
const dockerInstance = {
|
|
listContainers,
|
|
getContainer,
|
|
createContainer,
|
|
};
|
|
|
|
const constructorSpy = vi.fn();
|
|
class DockerConstructorMock {
|
|
constructor(options?: unknown) {
|
|
constructorSpy(options);
|
|
return dockerInstance;
|
|
}
|
|
}
|
|
|
|
const registerContainer = (container: MockDockerContainerState) => {
|
|
containers.set(container.id, { ...container });
|
|
ensureHandle(container.id);
|
|
};
|
|
|
|
const reset = () => {
|
|
containers.clear();
|
|
handles.clear();
|
|
constructorSpy.mockClear();
|
|
listContainers.mockClear();
|
|
getContainer.mockClear();
|
|
createContainer.mockClear();
|
|
};
|
|
|
|
return {
|
|
DockerConstructorMock,
|
|
constructorSpy,
|
|
createContainer,
|
|
handles,
|
|
registerContainer,
|
|
reset,
|
|
};
|
|
});
|
|
|
|
vi.mock("dockerode", () => ({
|
|
default: dockerMock.DockerConstructorMock,
|
|
}));
|
|
|
|
import { ContainerLifecycleService } from "./container-lifecycle.service";
|
|
|
|
function createConfigMock(values: Record<string, string> = {}) {
|
|
return {
|
|
get: vi.fn((key: string) => values[key]),
|
|
};
|
|
}
|
|
|
|
function createCryptoMock() {
|
|
return {
|
|
generateToken: vi.fn(() => "generated-token"),
|
|
encrypt: vi.fn((value: string) => `enc:${value}`),
|
|
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
|
|
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
|
|
};
|
|
}
|
|
|
|
function projectRecord(
|
|
record: MockUserContainerRecord,
|
|
select?: Record<string, boolean>
|
|
): Partial<MockUserContainerRecord> {
|
|
if (!select) {
|
|
return { ...record };
|
|
}
|
|
|
|
const projection: Partial<MockUserContainerRecord> = {};
|
|
for (const [field, enabled] of Object.entries(select)) {
|
|
if (enabled) {
|
|
const key = field as keyof MockUserContainerRecord;
|
|
projection[key] = record[key];
|
|
}
|
|
}
|
|
|
|
return projection;
|
|
}
|
|
|
|
function createPrismaMock(initialRecords: MockUserContainerRecord[] = []) {
|
|
const records = new Map<string, MockUserContainerRecord>();
|
|
for (const record of initialRecords) {
|
|
records.set(record.userId, { ...record });
|
|
}
|
|
|
|
const userContainer = {
|
|
findUnique: vi.fn(
|
|
async (args: {
|
|
where: { userId?: string; id?: string };
|
|
select?: Record<string, boolean>;
|
|
}) => {
|
|
let record: MockUserContainerRecord | undefined;
|
|
if (args.where.userId) {
|
|
record = records.get(args.where.userId);
|
|
} else if (args.where.id) {
|
|
record = [...records.values()].find((entry) => entry.id === args.where.id);
|
|
}
|
|
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
|
|
return projectRecord(record, args.select);
|
|
}
|
|
),
|
|
create: vi.fn(
|
|
async (args: {
|
|
data: Partial<MockUserContainerRecord> & {
|
|
userId: string;
|
|
containerName: string;
|
|
gatewayToken: string;
|
|
};
|
|
}) => {
|
|
const now = new Date();
|
|
const next: MockUserContainerRecord = {
|
|
id: args.data.id ?? `uc-${records.size + 1}`,
|
|
userId: args.data.userId,
|
|
containerId: args.data.containerId ?? null,
|
|
containerName: args.data.containerName,
|
|
gatewayPort: args.data.gatewayPort ?? null,
|
|
gatewayToken: args.data.gatewayToken,
|
|
status: args.data.status ?? "stopped",
|
|
lastActiveAt: args.data.lastActiveAt ?? null,
|
|
idleTimeoutMin: args.data.idleTimeoutMin ?? 30,
|
|
config: args.data.config ?? {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
records.set(next.userId, next);
|
|
return { ...next };
|
|
}
|
|
),
|
|
update: vi.fn(
|
|
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
|
|
const record = records.get(args.where.userId);
|
|
if (!record) {
|
|
throw new Error(`Record ${args.where.userId} not found`);
|
|
}
|
|
|
|
const updated: MockUserContainerRecord = {
|
|
...record,
|
|
...args.data,
|
|
updatedAt: new Date(),
|
|
};
|
|
records.set(updated.userId, updated);
|
|
return { ...updated };
|
|
}
|
|
),
|
|
updateMany: vi.fn(
|
|
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
|
|
const record = records.get(args.where.userId);
|
|
if (!record) {
|
|
return { count: 0 };
|
|
}
|
|
|
|
const updated: MockUserContainerRecord = {
|
|
...record,
|
|
...args.data,
|
|
updatedAt: new Date(),
|
|
};
|
|
records.set(updated.userId, updated);
|
|
return { count: 1 };
|
|
}
|
|
),
|
|
findMany: vi.fn(
|
|
async (args?: {
|
|
where?: {
|
|
status?: string;
|
|
lastActiveAt?: { not: null };
|
|
gatewayPort?: { not: null };
|
|
};
|
|
select?: Record<string, boolean>;
|
|
}) => {
|
|
let rows = [...records.values()];
|
|
|
|
if (args?.where?.status) {
|
|
rows = rows.filter((record) => record.status === args.where?.status);
|
|
}
|
|
|
|
if (args?.where?.lastActiveAt?.not === null) {
|
|
rows = rows.filter((record) => record.lastActiveAt !== null);
|
|
}
|
|
|
|
if (args?.where?.gatewayPort?.not === null) {
|
|
rows = rows.filter((record) => record.gatewayPort !== null);
|
|
}
|
|
|
|
return rows.map((record) => projectRecord(record, args?.select));
|
|
}
|
|
),
|
|
};
|
|
|
|
return {
|
|
prisma: {
|
|
userContainer,
|
|
},
|
|
records,
|
|
};
|
|
}
|
|
|
|
function createRecord(overrides: Partial<MockUserContainerRecord>): MockUserContainerRecord {
|
|
const now = new Date();
|
|
return {
|
|
id: overrides.id ?? "uc-default",
|
|
userId: overrides.userId ?? "user-default",
|
|
containerId: overrides.containerId ?? null,
|
|
containerName: overrides.containerName ?? "mosaic-user-user-default",
|
|
gatewayPort: overrides.gatewayPort ?? null,
|
|
gatewayToken: overrides.gatewayToken ?? "enc:token-default",
|
|
status: overrides.status ?? "stopped",
|
|
lastActiveAt: overrides.lastActiveAt ?? null,
|
|
idleTimeoutMin: overrides.idleTimeoutMin ?? 30,
|
|
config: overrides.config ?? {},
|
|
createdAt: overrides.createdAt ?? now,
|
|
updatedAt: overrides.updatedAt ?? now,
|
|
};
|
|
}
|
|
|
|
describe("ContainerLifecycleService", () => {
|
|
beforeEach(() => {
|
|
dockerMock.reset();
|
|
});
|
|
|
|
it("ensureRunning creates container when none exists", async () => {
|
|
const { prisma, records } = createPrismaMock();
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
const result = await service.ensureRunning("user-1");
|
|
|
|
expect(result).toEqual({
|
|
url: "http://mosaic-user-user-1:19000",
|
|
token: "generated-token",
|
|
});
|
|
|
|
const updatedRecord = records.get("user-1");
|
|
expect(updatedRecord?.status).toBe("running");
|
|
expect(updatedRecord?.containerId).toBe("ctr-1");
|
|
expect(updatedRecord?.gatewayPort).toBe(19000);
|
|
expect(updatedRecord?.gatewayToken).toBe("enc:generated-token");
|
|
|
|
expect(dockerMock.createContainer).toHaveBeenCalledTimes(1);
|
|
const [createCall] = dockerMock.createContainer.mock.calls[0] as [
|
|
{
|
|
name: string;
|
|
Image: string;
|
|
Env: string[];
|
|
HostConfig: { Binds: string[]; NetworkMode: string };
|
|
},
|
|
];
|
|
expect(createCall.name).toBe("mosaic-user-user-1");
|
|
expect(createCall.Image).toBe("alpine/openclaw:latest");
|
|
expect(createCall.HostConfig.Binds).toEqual(["mosaic-user-user-1-state:/home/node/.openclaw"]);
|
|
expect(createCall.HostConfig.NetworkMode).toBe("mosaic-internal");
|
|
expect(createCall.Env).toContain("AGENT_TOKEN=generated-token");
|
|
});
|
|
|
|
it("ensureRunning starts existing stopped container", async () => {
|
|
const { prisma, records } = createPrismaMock([
|
|
createRecord({
|
|
id: "uc-1",
|
|
userId: "user-2",
|
|
containerId: "ctr-stopped",
|
|
containerName: "mosaic-user-user-2",
|
|
gatewayToken: "enc:existing-token",
|
|
status: "stopped",
|
|
}),
|
|
]);
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
dockerMock.registerContainer({
|
|
id: "ctr-stopped",
|
|
name: "mosaic-user-user-2",
|
|
running: false,
|
|
port: 19042,
|
|
});
|
|
|
|
const result = await service.ensureRunning("user-2");
|
|
|
|
expect(result).toEqual({
|
|
url: "http://mosaic-user-user-2:19042",
|
|
token: "existing-token",
|
|
});
|
|
|
|
const handle = dockerMock.handles.get("ctr-stopped");
|
|
expect(handle?.start).toHaveBeenCalledTimes(1);
|
|
expect(records.get("user-2")?.status).toBe("running");
|
|
expect(records.get("user-2")?.gatewayPort).toBe(19042);
|
|
});
|
|
|
|
it("ensureRunning returns existing running container", async () => {
|
|
const { prisma } = createPrismaMock([
|
|
createRecord({
|
|
id: "uc-2",
|
|
userId: "user-3",
|
|
containerId: "ctr-running",
|
|
containerName: "mosaic-user-user-3",
|
|
gatewayPort: 19043,
|
|
gatewayToken: "enc:running-token",
|
|
status: "running",
|
|
}),
|
|
]);
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
dockerMock.registerContainer({
|
|
id: "ctr-running",
|
|
name: "mosaic-user-user-3",
|
|
running: true,
|
|
port: 19043,
|
|
});
|
|
|
|
const result = await service.ensureRunning("user-3");
|
|
|
|
expect(result).toEqual({
|
|
url: "http://mosaic-user-user-3:19043",
|
|
token: "running-token",
|
|
});
|
|
|
|
expect(dockerMock.createContainer).not.toHaveBeenCalled();
|
|
const handle = dockerMock.handles.get("ctr-running");
|
|
expect(handle?.start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stop gracefully stops container and updates DB", async () => {
|
|
const { prisma, records } = createPrismaMock([
|
|
createRecord({
|
|
id: "uc-stop",
|
|
userId: "user-stop",
|
|
containerId: "ctr-stop",
|
|
containerName: "mosaic-user-user-stop",
|
|
gatewayPort: 19044,
|
|
status: "running",
|
|
}),
|
|
]);
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
dockerMock.registerContainer({
|
|
id: "ctr-stop",
|
|
name: "mosaic-user-user-stop",
|
|
running: true,
|
|
port: 19044,
|
|
});
|
|
|
|
await service.stop("user-stop");
|
|
|
|
const handle = dockerMock.handles.get("ctr-stop");
|
|
expect(handle?.stop).toHaveBeenCalledWith({ t: 10 });
|
|
|
|
const updatedRecord = records.get("user-stop");
|
|
expect(updatedRecord?.status).toBe("stopped");
|
|
expect(updatedRecord?.containerId).toBeNull();
|
|
expect(updatedRecord?.gatewayPort).toBeNull();
|
|
});
|
|
|
|
it("reapIdle stops only containers past their idle timeout", async () => {
|
|
const now = Date.now();
|
|
const { prisma, records } = createPrismaMock([
|
|
createRecord({
|
|
id: "uc-old",
|
|
userId: "user-old",
|
|
containerId: "ctr-old",
|
|
containerName: "mosaic-user-user-old",
|
|
gatewayPort: 19045,
|
|
status: "running",
|
|
lastActiveAt: new Date(now - 60 * 60 * 1000),
|
|
idleTimeoutMin: 30,
|
|
}),
|
|
createRecord({
|
|
id: "uc-fresh",
|
|
userId: "user-fresh",
|
|
containerId: "ctr-fresh",
|
|
containerName: "mosaic-user-user-fresh",
|
|
gatewayPort: 19046,
|
|
status: "running",
|
|
lastActiveAt: new Date(now - 5 * 60 * 1000),
|
|
idleTimeoutMin: 30,
|
|
}),
|
|
]);
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
dockerMock.registerContainer({
|
|
id: "ctr-old",
|
|
name: "mosaic-user-user-old",
|
|
running: true,
|
|
port: 19045,
|
|
});
|
|
dockerMock.registerContainer({
|
|
id: "ctr-fresh",
|
|
name: "mosaic-user-user-fresh",
|
|
running: true,
|
|
port: 19046,
|
|
});
|
|
|
|
const result = await service.reapIdle();
|
|
|
|
expect(result).toEqual({
|
|
stopped: ["user-old"],
|
|
});
|
|
|
|
expect(records.get("user-old")?.status).toBe("stopped");
|
|
expect(records.get("user-fresh")?.status).toBe("running");
|
|
|
|
const oldHandle = dockerMock.handles.get("ctr-old");
|
|
const freshHandle = dockerMock.handles.get("ctr-fresh");
|
|
expect(oldHandle?.stop).toHaveBeenCalledTimes(1);
|
|
expect(freshHandle?.stop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("touch updates lastActiveAt", async () => {
|
|
const { prisma, records } = createPrismaMock([
|
|
createRecord({
|
|
id: "uc-touch",
|
|
userId: "user-touch",
|
|
containerName: "mosaic-user-user-touch",
|
|
lastActiveAt: null,
|
|
}),
|
|
]);
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
await service.touch("user-touch");
|
|
|
|
const updatedRecord = records.get("user-touch");
|
|
expect(updatedRecord?.lastActiveAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("getStatus returns null for unknown user", async () => {
|
|
const { prisma } = createPrismaMock();
|
|
const crypto = createCryptoMock();
|
|
const config = createConfigMock();
|
|
const service = new ContainerLifecycleService(
|
|
prisma as unknown as PrismaService,
|
|
crypto as unknown as CryptoService,
|
|
config as unknown as ConfigService
|
|
);
|
|
|
|
const status = await service.getStatus("missing-user");
|
|
|
|
expect(status).toBeNull();
|
|
});
|
|
});
|