feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

+67 -6
View File
@@ -1,14 +1,75 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startFleetsService, stopFleetsService } from "./routes.js";
import { fleets, fleetMembers, fleetInventory } from "./tables.js";
import { createFleetRepository } from "./repository.js";
import { registerFleetRoutes } from "./routes.js";
/** What other plugins get from ctx.services.get("fleets.access", {userId}). */
export interface FleetsService {
/** The calling user's fleets, without resolving membership. */
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
/** A fleet's effective member hosts (static membership union tag matches). */
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
/** Creates a fleet owned by the calling user. */
create: (input: {
name: string;
description?: string | null;
}) => Promise<{ id: number; name: string }>;
/** Adds a host the calling user owns to a fleet's static membership. */
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startFleetsService(ctx.http.router());
const fleetsTable = await ctx.db.define(fleets);
const membersTable = await ctx.db.define(fleetMembers);
const inventoryTable = await ctx.db.define(fleetInventory);
const repo = createFleetRepository(
ctx.db,
ctx.hosts,
fleetsTable,
membersTable,
inventoryTable,
);
registerFleetRoutes(ctx.http.router<Router>(), repo, ctx);
const service: FleetsService = {
list: async () => {
const userId = ctx.currentActor();
if (!userId) return [];
const rows = await repo.listByUser(userId);
return rows.map((f) => ({ id: f.id, name: f.name, color: f.color }));
},
members: async (fleetId) => {
const userId = ctx.currentActor();
if (!userId) return [];
const members = await repo.listEffectiveMembers(userId, fleetId);
return members.map((m) => ({ id: m.id, name: m.name, ip: m.ip }));
},
create: async (input) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("fleets.access.create needs an actor");
const created = await repo.create(userId, input);
return { id: created.id, name: created.name };
},
addMember: async (fleetId, hostId) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("fleets.access.addMember needs an actor");
const fleet = await repo.findById(userId, fleetId);
if (!fleet) throw new Error("Fleet not found");
await repo.addMember(fleetId, hostId);
},
};
ctx.services.provide("fleets.access", service);
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
}
export async function deactivate() {
stopFleetsService();
// Everything above was registered through ctx and is disposed by core.
}
+370
View File
@@ -0,0 +1,370 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { PluginDatabase, PluginHosts } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The tables come from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface FleetRecord {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string | null;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export interface FleetCreateInput {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetUpdateInput {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryInput {
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
}
function parseTagRules(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((t): t is string => typeof t === "string")
: [];
} catch {
return [];
}
}
export type FleetRepository = ReturnType<typeof createFleetRepository>;
/**
* Fleets, membership and inventory, written without RETURNING so it runs the
* same on all three engines: an insert is read back by its sync id, an
* update by its own id.
*/
export function createFleetRepository(
db: PluginDatabase,
hosts: PluginHosts,
fleetsTable: Table,
membersTable: Table,
inventoryTable: Table,
) {
const client = () => db.client<Drizzle>();
async function findById(
userId: string,
fleetId: number,
): Promise<FleetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(fleetsTable)
.where(and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)))
.limit(1);
return (rows[0] as FleetRecord) ?? null;
}
async function findBySyncId(syncId: string): Promise<FleetRecord> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(fleetsTable)
.where(eq(fleetsTable.syncId, syncId))
.limit(1);
return rows[0] as FleetRecord;
}
async function listStaticMemberIds(fleetId: number): Promise<number[]> {
const drizzle = await client();
const rows = await drizzle
.select({ hostId: membersTable.hostId })
.from(membersTable)
.where(eq(membersTable.fleetId, fleetId));
return rows.map((r: { hostId: number }) => r.hostId);
}
return {
async listByUser(userId: string): Promise<FleetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(fleetsTable)
.where(eq(fleetsTable.userId, userId));
},
findById,
listStaticMemberIds,
async create(
userId: string,
input: FleetCreateInput,
now = new Date().toISOString(),
): Promise<FleetRecord> {
const syncId = randomUUID();
const drizzle = await client();
await drizzle.insert(fleetsTable).values({
userId,
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
icon: input.icon ?? null,
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
syncId,
createdAt: now,
updatedAt: now,
});
await db.persist();
return findBySyncId(syncId);
},
async update(
userId: string,
fleetId: number,
input: FleetUpdateInput,
now = new Date().toISOString(),
): Promise<FleetRecord | null> {
const existing = await findById(userId, fleetId);
if (!existing) return null;
const drizzle = await client();
await drizzle
.update(fleetsTable)
.set({
name: input.name ?? existing.name,
description:
input.description === undefined
? existing.description
: input.description,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
tagRules:
input.tagRules === undefined
? existing.tagRules
: JSON.stringify(input.tagRules),
updatedAt: now,
})
.where(
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
);
await db.persist();
return findById(userId, fleetId);
},
async delete(userId: string, fleetId: number): Promise<boolean> {
const existing = await findById(userId, fleetId);
if (!existing) return false;
const drizzle = await client();
await drizzle
.delete(fleetsTable)
.where(
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
);
await db.persist();
return true;
},
async addMember(
fleetId: number,
hostId: number,
now = new Date().toISOString(),
): Promise<void> {
const drizzle = await client();
const existing = await drizzle
.select({ id: membersTable.id })
.from(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
)
.limit(1);
if (existing.length > 0) return;
await drizzle
.insert(membersTable)
.values({ fleetId, hostId, addedAt: now });
await db.persist();
},
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: membersTable.id })
.from(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
)
.limit(1);
if (existing.length === 0) return false;
await drizzle
.delete(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
);
await db.persist();
return true;
},
/**
* Effective membership = static members rows union hosts whose
* comma-separated tags string intersects any tag in the fleet's
* tagRules. Both sets are scoped to hosts the fleet owner can see
* (ctx.hosts.list already resolves owner-only, since a fleet only holds
* one user's hosts) and deduplicated by id.
*/
async listEffectiveMembers(userId: string, fleetId: number) {
const fleet = await findById(userId, fleetId);
if (!fleet) return [];
const staticIds = await listStaticMemberIds(fleetId);
const tagRules = parseTagRules(fleet.tagRules);
const ownedHosts = (await hosts.list()).filter(
(host) => host.userId === userId,
);
const byId = new Map<number, (typeof ownedHosts)[number]>();
for (const host of ownedHosts) {
if (staticIds.includes(host.id)) {
byId.set(host.id, host);
continue;
}
if (tagRules.length === 0) continue;
const hostTags = (host.tags ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (hostTags.some((tag) => tagRules.includes(tag))) {
byId.set(host.id, host);
}
}
return Array.from(byId.values());
},
async deleteByUserId(userId: string): Promise<number> {
const drizzle = await client();
const userFleets = await drizzle
.select({ id: fleetsTable.id })
.from(fleetsTable)
.where(eq(fleetsTable.userId, userId));
if (userFleets.length === 0) return 0;
await drizzle.delete(fleetsTable).where(eq(fleetsTable.userId, userId));
await db.persist();
return userFleets.length;
},
async upsertInventory(
userId: string,
hostId: number,
input: FleetInventoryInput,
now = new Date().toISOString(),
): Promise<FleetInventoryRecord> {
const drizzle = await client();
const existing = await drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.hostId, hostId),
eq(inventoryTable.userId, userId),
),
)
.limit(1);
if (existing[0]) {
await drizzle
.update(inventoryTable)
.set({ ...input, collectedAt: now })
.where(eq(inventoryTable.id, existing[0].id));
await db.persist();
const [updated] = await drizzle
.select()
.from(inventoryTable)
.where(eq(inventoryTable.id, existing[0].id))
.limit(1);
return updated as FleetInventoryRecord;
}
await drizzle
.insert(inventoryTable)
.values({ userId, hostId, ...input, collectedAt: now });
await db.persist();
const [created] = await drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.hostId, hostId),
eq(inventoryTable.userId, userId),
),
)
.limit(1);
return created as FleetInventoryRecord;
},
async listInventoryForHosts(
userId: string,
hostIds: number[],
): Promise<FleetInventoryRecord[]> {
if (hostIds.length === 0) return [];
const drizzle = await client();
return drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.userId, userId),
inArray(inventoryTable.hostId, hostIds),
),
);
},
};
}
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+98
View File
@@ -0,0 +1,98 @@
import {
adoptLegacyTable,
defineTable,
id,
integer,
refHost,
refUser,
text,
} from "@termix/plugin-sdk/db";
/**
* A fleet: a named group of hosts, by static membership or tag rules.
*
* Adopted from core's "fleets", so column and index names are the legacy
* ones: the rename carries the existing rows across.
*/
export const fleets = adoptLegacyTable(
"fleets",
defineTable("fleets", {
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
color: text(),
icon: text(),
// JSON array of tag strings, unioned with static members at resolution
// time. Kept to tag-equality matching for v1.
tagRules: text(),
syncId: text().unique(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* Static fleet membership. A host can also be an effective member through a
* tag rule match, resolved in the repository rather than stored here.
*
* fleetId has no declared refUser/refHost FK: the SDK's referenceable targets
* are core's users and ssh_data tables only, not another table this same
* plugin owns. The legacy foreign key to fleets(id) is preserved by the
* adoption migration's CREATE TABLE, which is why the migration file keeps
* the FOREIGN KEY clause the generator does not emit for this column.
*/
export const fleetMembers = adoptLegacyTable(
"fleet_members",
defineTable(
"members",
{
id: id(),
fleetId: integer().notNull(),
hostId: refHost(),
addedAt: text().notNull().defaultNow(),
},
{
// fleetId leads the unique pair, so listing a fleet's hosts is already
// served. Finding the fleets a host belongs to starts from hostId.
uniques: [
{
name: "idx_fleet_members_fleet_host",
columns: ["fleetId", "hostId"],
},
],
indexes: [{ name: "idx_fleet_members_host", columns: ["hostId"] }],
},
),
);
// Latest-only inventory snapshot per host, overwritten on each refresh - no
// historical log, matching the "latest snapshot only" scope decision.
export const fleetInventory = adoptLegacyTable(
"fleet_inventory",
defineTable(
"inventory",
{
id: id(),
hostId: refHost(),
userId: refUser(),
osPrettyName: text(),
kernel: text(),
architecture: text(),
hostname: text(),
uptimeSeconds: integer(),
ip: text(),
packageManager: text(),
collectedAt: text().notNull().defaultNow(),
},
{
// hostId leads the unique pair; a user's whole inventory reads by userId.
uniques: [
{ name: "idx_fleet_inventory_host", columns: ["hostId", "userId"] },
],
indexes: [{ name: "idx_fleet_inventory_user", columns: ["userId"] }],
},
),
);
export const tables = [fleets, fleetMembers, fleetInventory];