mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the docker plugin (B15)
This commit is contained in:
1 parent
0a3f05d99d
commit
993a91c899
167 files changed
+26299
-5136
No files matched your search
@@ -1,108 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const fired = vi.hoisted(() => [] as unknown[]);
|
||||
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
onDockerEvent: async (event: unknown) => {
|
||||
fired.push(event);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => ({ listAllEnabled: async () => [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
reconcileDockerWatch,
|
||||
resetDockerWatcher,
|
||||
} from "../../src/backend/docker-watcher.js";
|
||||
import {
|
||||
setDockerServices,
|
||||
runDockerAction,
|
||||
} from "../../src/backend/docker.js";
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part: turning
|
||||
* `docker ps` output into states, and turning two snapshots into events.
|
||||
*/
|
||||
type Listener = (event: {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: string;
|
||||
}) => void;
|
||||
|
||||
describe("parseContainerStates", () => {
|
||||
it("reads name, state and health out of the ps output", () => {
|
||||
const output = [
|
||||
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||
'{"name":"db","state":"exited","status":"Exited (0) 5 minutes ago"}',
|
||||
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||
].join("\n");
|
||||
/** A docker.events provider that records who subscribed to what. */
|
||||
function fakeEvents() {
|
||||
const subscriptions = new Map<string, Set<Listener>>();
|
||||
const calls: Array<{ userId?: string; hostId: number }> = [];
|
||||
const services = {
|
||||
provide: () => {},
|
||||
providers: () => [],
|
||||
get: (name: string, options?: { userId?: string }) => {
|
||||
if (name !== "docker.events") return {};
|
||||
return {
|
||||
subscribe: async (hostId: number, listener: Listener) => {
|
||||
calls.push({ userId: options?.userId, hostId });
|
||||
const key = `${options?.userId}:${hostId}`;
|
||||
const set = subscriptions.get(key) ?? new Set<Listener>();
|
||||
set.add(listener);
|
||||
subscriptions.set(key, set);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PluginServices;
|
||||
const emit = (key: string, event: Parameters<Listener>[0]) => {
|
||||
for (const listener of subscriptions.get(key) ?? []) listener(event);
|
||||
};
|
||||
return { services, subscriptions, calls, emit };
|
||||
}
|
||||
|
||||
const states = parseContainerStates(output);
|
||||
|
||||
expect(states.get("web")).toEqual({ state: "running", unhealthy: false });
|
||||
expect(states.get("db")).toEqual({ state: "exited", unhealthy: false });
|
||||
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||
});
|
||||
|
||||
it("skips blank and malformed lines rather than failing the poll", () => {
|
||||
const output = [
|
||||
'{"name":"web","state":"running","status":"Up"}',
|
||||
"",
|
||||
"not json at all",
|
||||
'{"state":"running"}',
|
||||
].join("\n");
|
||||
|
||||
const states = parseContainerStates(output);
|
||||
expect([...states.keys()]).toEqual(["web"]);
|
||||
});
|
||||
|
||||
it("returns nothing for empty output", () => {
|
||||
expect(parseContainerStates("").size).toBe(0);
|
||||
});
|
||||
beforeEach(() => {
|
||||
fired.length = 0;
|
||||
});
|
||||
|
||||
describe("diffContainerStates", () => {
|
||||
const running = { state: "running", unhealthy: false };
|
||||
const exited = { state: "exited", unhealthy: false };
|
||||
afterEach(() => {
|
||||
resetDockerWatcher();
|
||||
setDockerServices(null);
|
||||
});
|
||||
|
||||
it("reports a container that stopped", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", exited]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "exited" }]);
|
||||
describe("docker watcher", () => {
|
||||
it("subscribes each watched host as its owner and fires the trigger", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
|
||||
await reconcileDockerWatch(0, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toEqual([{ userId: "user-1", hostId: 7 }]);
|
||||
|
||||
events.emit("user-1:7", { hostId: 7, container: "web", event: "exited" });
|
||||
await Promise.resolve();
|
||||
expect(fired).toEqual([
|
||||
{ hostId: 7, ownerUserId: "user-1", container: "web", event: "exited" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a container that started", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", exited]]),
|
||||
new Map([["web", running]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "started" }]);
|
||||
it("re-subscribes once a minute and drops hosts no longer watched", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
const watched = new Map([[7, "user-1"]]);
|
||||
|
||||
await reconcileDockerWatch(1_000, watched);
|
||||
await reconcileDockerWatch(30_000, watched);
|
||||
expect(events.calls).toHaveLength(1);
|
||||
await reconcileDockerWatch(62_000, watched);
|
||||
expect(events.calls).toHaveLength(2);
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(1);
|
||||
|
||||
await reconcileDockerWatch(63_000, new Map());
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a container that began restarting", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", { state: "restarting", unhealthy: false }]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "restarting" }]);
|
||||
it("does nothing while the docker plugin is off, then picks it up", async () => {
|
||||
setDockerServices({
|
||||
get: () => ({}),
|
||||
} as unknown as PluginServices);
|
||||
await expect(
|
||||
reconcileDockerWatch(0, new Map([[7, "user-1"]])),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
await reconcileDockerWatch(1, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a container that went unhealthy while still running", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", { state: "running", unhealthy: true }]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "unhealthy" }]);
|
||||
it("fails a docker step cleanly while the plugin is off", async () => {
|
||||
setDockerServices({ get: () => ({}) } as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The docker plugin is not available",
|
||||
});
|
||||
});
|
||||
|
||||
it("stays quiet when nothing changed", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", running]]),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not re-announce a container that is still unhealthy", () => {
|
||||
const unhealthy = { state: "running", unhealthy: true };
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", unhealthy]]),
|
||||
new Map([["web", unhealthy]]),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
// A first sighting is a baseline, not an event: otherwise every container
|
||||
// running at boot would report itself as freshly started.
|
||||
it("treats a newly seen container as a baseline", () => {
|
||||
const events = diffContainerStates(new Map(), new Map([["web", running]]));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a container that disappeared", () => {
|
||||
const events = diffContainerStates(new Map([["web", running]]), new Map());
|
||||
expect(events).toEqual([]);
|
||||
it("runs a docker step through the docker service", async () => {
|
||||
const action = vi.fn(async () => {});
|
||||
setDockerServices({
|
||||
get: (name: string) => (name === "docker.containers" ? { action } : {}),
|
||||
} as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(action).toHaveBeenCalledWith(7, "web", "restart");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user