Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/migrations/2026-07-10-vm-host-link-groups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- 2026-07-10: vm_host link groups (#2308) — member-role column on devices.
--
-- Extends the #2138 device_link_groups mechanism with an asymmetric kind:
-- 'vm_host' groups one host server record with its guest-VM device records so
-- the guests nest under the host in the device list. Unlike multiboot (peer
-- boot profiles, only one online at a time), host + guests are concurrently
-- online — the grouping is hierarchical organization, not offline-noise
-- suppression, and guests stay fully managed endpoints.
--
-- The 2026-07-09 migration reserved exactly this design: kind='vm_host' on
-- device_link_groups (varchar, no CHECK — app-enforced, same as 'multiboot')
-- plus a member-role column on devices. This adds that column:
-- link_group_role: NULL for unlinked devices and for members of symmetric
-- kinds (multiboot); 'host' | 'guest' for members of a vm_host group
-- (app-enforced values, matching the kind convention). Invariant:
-- link_group_id IS NULL => link_group_role IS NULL — every unlink path
-- (PATCH remove, group delete/dissolve, move-org) clears both together.
--
-- Tenancy: no RLS changes. `devices` already carries the standard org policies
-- and the composite FK devices(link_group_id, org_id) ->
-- device_link_groups(id, org_id) from 2026-07-09 keeps enforcing the same-org
-- invariant; a role tag on the membership column changes nothing about scope.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS. autoMigrate wraps the file in one
-- transaction — no inner BEGIN/COMMIT.

ALTER TABLE devices ADD COLUMN IF NOT EXISTS link_group_role varchar(16);
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,65 @@ describe('device link-group dissolution', () => {
expect(survivor[0]?.linkGroupId).toBeNull();
});

runDb('vm_host group survives guest loss but dissolves headless when the host unlinks (#2308)', async () => {
const { orgA, deviceA1, deviceA2 } = await seed();

// deviceA1 = host, deviceA2 = guest.
const groupId = await withSystemDbAccessContext(async () => {
const [group] = await db
.insert(deviceLinkGroups)
.values({ orgId: orgA.id, kind: 'vm_host', name: 'hv-01' })
.returning({ id: deviceLinkGroups.id });
await db
.update(devices)
.set({ linkGroupId: group!.id, linkGroupRole: 'host' })
.where(eq(devices.id, deviceA1.id));
await db
.update(devices)
.set({ linkGroupId: group!.id, linkGroupRole: 'guest' })
.where(eq(devices.id, deviceA2.id));
return group!.id;
});

// Host + guest present: no dissolution.
const keptOpen = await withSystemDbAccessContext(() => dissolveLinkGroupIfBelowMinimum(db, groupId));
expect(keptOpen).toBe(false);

// Unlink the HOST (as a PATCH remove / move-org would): the guest remains
// but the group is headless — the dissolve check must remove it and clear
// the guest's membership AND role.
await withSystemDbAccessContext(async () => {
await db
.update(devices)
.set({ linkGroupId: null, linkGroupRole: null })
.where(eq(devices.id, deviceA1.id));
// Re-seed a second guest so the member count stays >= 2 and ONLY the
// headless rule (not the below-minimum rule) can dissolve the group.
const extra = await seedDevice(orgA.id, (await db.select({ siteId: devices.siteId }).from(devices).where(eq(devices.id, deviceA2.id)))[0]!.siteId!, `extra-${Date.now()}`);
await db
.update(devices)
.set({ linkGroupId: groupId, linkGroupRole: 'guest' })
.where(eq(devices.id, extra.id));
});

const dissolved = await withSystemDbAccessContext(() => dissolveLinkGroupIfBelowMinimum(db, groupId));
expect(dissolved).toBe(true);

const remaining = await withSystemDbAccessContext(() =>
db.select({ id: deviceLinkGroups.id }).from(deviceLinkGroups).where(eq(deviceLinkGroups.id, groupId)),
);
expect(remaining).toHaveLength(0);

const exGuest = await withSystemDbAccessContext(() =>
db
.select({ linkGroupId: devices.linkGroupId, linkGroupRole: devices.linkGroupRole })
.from(devices)
.where(eq(devices.id, deviceA2.id)),
);
expect(exGuest[0]?.linkGroupId).toBeNull();
expect(exGuest[0]?.linkGroupRole).toBeNull();
});

runDb('deleteLinkGroup unlinks every member and removes the group row', async () => {
const { orgA, deviceA1, deviceA2 } = await seed();

Expand Down
16 changes: 12 additions & 4 deletions apps/api/src/db/schema/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export const devices = pgTable('devices', {
// matching the users(org_id, partner_id) composite-FK convention — pins every
// member of a group to the group's org (same-org invariant).
linkGroupId: uuid('link_group_id'),
// Member role within an ASYMMETRIC link group (#2308). NULL for unlinked
// devices and for members of symmetric kinds (multiboot — all peers). For a
// kind='vm_host' group exactly one member is 'host' (the hypervisor/server
// record) and the rest are 'guest' (its VMs). Values are app-enforced
// ('host' | 'guest'), matching kind's varchar-without-CHECK convention.
// Invariant: link_group_id IS NULL => link_group_role IS NULL (every unlink
// path clears both together).
linkGroupRole: varchar('link_group_role', { length: 16 }),
tags: text('tags').array().default([]),
customFields: jsonb('custom_fields').default({}),
managementPosture: jsonb('management_posture'),
Expand Down Expand Up @@ -129,10 +137,10 @@ export const devices = pgTable('devices', {
export const deviceLinkGroups = pgTable('device_link_groups', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').notNull().references(() => organizations.id),
// What the link MEANS. 'multiboot' (v1): members are peer boot profiles of
// one physical machine. Reserved future value: 'vm_host' (VM guests nested
// under their host server) — schema accommodation only; a future asymmetric
// kind adds a member-role column on devices (multiboot members are peers).
// What the link MEANS. 'multiboot' (v1, #2138): members are peer boot
// profiles of one physical machine. 'vm_host' (#2308): asymmetric — one
// member is the host server (devices.link_group_role = 'host') and the rest
// are its guest VMs ('guest'), nested under the host in the device list.
kind: varchar('kind', { length: 32 }).notNull().default('multiboot'),
name: varchar('name', { length: 255 }),
createdBy: uuid('created_by').references(() => users.id),
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/routes/devices/core.list-response-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { Hono } from 'hono';
// failure mode — `pendingReboot` is selected from the DB in core.ts but was
// omitted by the same response mapper, so the list/grid badge never rendered
// (the device-detail page worked because it returns the full row).
//
// Extended for #2138/#2308: the device-list link-group scalars
// (`linkGroupId`, `linkGroupRole`) drive the client-side multiboot grouping
// and vm_host guest nesting. If the mapper drops either, every linked device
// silently renders ungrouped while all other tests stay green — the exact
// dropped-field failure mode this file exists to catch.

vi.mock('../../db', () => ({
runOutsideDbContext: vi.fn((fn) => fn()),
Expand Down Expand Up @@ -140,6 +146,8 @@ describe('GET /devices — response shape', () => {
lastUser: null,
uptimeSeconds: null,
isHeadless: false,
linkGroupId: '44444444-4444-4444-8444-444444444444',
linkGroupRole: 'host',
createdAt: new Date('2026-05-26T19:39:57.519Z'),
updatedAt: new Date('2026-05-26T19:41:26.390Z'),
cpuModel: null,
Expand Down Expand Up @@ -170,6 +178,11 @@ describe('GET /devices — response shape', () => {
// #1720 — reliability score + trend surfaced for the list column.
expect(row).toHaveProperty('reliabilityScore', 42);
expect(row).toHaveProperty('reliabilityTrend', 'degrading');
// #2138/#2308 — link-group scalars must survive the mapper: the web list
// groups multiboot rows by linkGroupId and nests vm_host guests by
// linkGroupRole; dropping either silently un-groups every linked device.
expect(row).toHaveProperty('linkGroupId', '44444444-4444-4444-8444-444444444444');
expect(row).toHaveProperty('linkGroupRole', 'host');
});

it('returns null watchdogStatus / mainAgentSilentSince for healthy rows (still present in shape)', async () => {
Expand Down Expand Up @@ -201,6 +214,8 @@ describe('GET /devices — response shape', () => {
lastUser: null,
uptimeSeconds: null,
isHeadless: false,
linkGroupId: null,
linkGroupRole: null,
createdAt: new Date(),
updatedAt: new Date(),
cpuModel: null,
Expand Down Expand Up @@ -239,5 +254,11 @@ describe('GET /devices — response shape', () => {
expect(row.mainAgentSilentSince).toBeNull();
expect(row.watchdogVersion).toBeNull();
expect(row.pendingReboot).toBe(false);

// #2138/#2308 — link-group keys present (null) even for unlinked devices.
expect(Object.prototype.hasOwnProperty.call(row, 'linkGroupId')).toBe(true);
expect(Object.prototype.hasOwnProperty.call(row, 'linkGroupRole')).toBe(true);
expect(row.linkGroupId).toBeNull();
expect(row.linkGroupRole).toBeNull();
});
});
29 changes: 25 additions & 4 deletions apps/api/src/routes/devices/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,10 @@ coreRoutes.get(
// Linked multi-boot profiles (#2138): null => unlinked. The web list
// groups rows client-side by this id (inactive strips / group bar).
linkGroupId: devices.linkGroupId,
// vm_host member role (#2308): 'host' | 'guest' | null. Lets the web
// list nest guest rows under their host without joining the group
// table — a non-null role implies the group's kind is 'vm_host'.
linkGroupRole: devices.linkGroupRole,
createdAt: devices.createdAt,
updatedAt: devices.updatedAt,
// Hardware summary
Expand Down Expand Up @@ -688,6 +692,7 @@ coreRoutes.get(
batteryStatus: d.batteryStatus ?? null,
activeVpns: d.activeVpns ?? null,
linkGroupId: d.linkGroupId ?? null,
linkGroupRole: d.linkGroupRole ?? null,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
cpuPercent: latestMetrics?.cpuPercent ?? 0,
Expand Down Expand Up @@ -1356,6 +1361,12 @@ coreRoutes.delete(
}
}

// #2138/#2308 — whether deleting this device dissolved its link group
// (lone multiboot survivor unlinked, or a vm_host group left headless and
// its guests unlinked). Recorded in the audit details: an unexplained
// "why did this whole VM group un-group?" must be traceable to this event.
let linkGroupDissolved = false;

// Cascade: remove all FK-referencing records in a transaction.
// Uses raw SQL to cover all child tables without importing each schema.
// When adding new tables with device_id FK, add them here too.
Expand Down Expand Up @@ -1388,10 +1399,11 @@ coreRoutes.delete(
await tx.delete(devices).where(eq(devices.id, deviceId));

// #2138 — the deleted device's link_group_id went with its row. If it
// was a boot profile and the group now has a single lone survivor,
// dissolve the (meaningless) group.
// was a boot profile and the group now has a single lone survivor —
// or it was a vm_host group's HOST (#2308), leaving the group
// headless — dissolve the group.
if (device.linkGroupId) {
await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId);
linkGroupDissolved = await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId);
}
});
} catch (err: unknown) {
Expand All @@ -1413,7 +1425,16 @@ coreRoutes.delete(
resourceType: 'device',
resourceId: deviceId,
resourceName: device.hostname ?? device.displayName ?? deviceId,
details: { uninstallCommandSent: uninstallSent }
details: {
uninstallCommandSent: uninstallSent,
// #2138/#2308 — deleting a linked device can dissolve its link group
// (and unlink every remaining member). Without this flag the audit
// trail would show only "device deleted" while sibling devices
// silently lost their grouping.
...(device.linkGroupId
? { linkGroupId: device.linkGroupId, linkGroupDissolved }
: {}),
}
});

return c.json({
Expand Down
Loading
Loading