fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-26 18:18:12 -05:00
1 parent c38c2e84b8
commit 710f545e34
14 files changed
+4938 -16

No files matched your search

+6
View File
@@ -24,6 +24,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build plugin SDK
run: npm run build:sdk
- name: Lint
# npm run lint, not npx eslint — the script also checks that the
# generated dialect schemas match schema.ts, which eslint cannot see.
@@ -94,6 +97,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build plugin SDK
run: npm run build:sdk
# Each run applies the migrations to an empty database first, so a
# migration that does not apply cleanly fails the build.
- name: Verify Postgres
+2 -2
View File
@@ -1,7 +1,7 @@
CREATE TABLE `plugin_settings` (
`id` int AUTO_INCREMENT NOT NULL,
`plugin_id` varchar(255) NOT NULL,
`scope` varchar(255) NOT NULL,
`scope` enum('admin','user','host','secret') NOT NULL,
`scope_id` varchar(255),
`key` varchar(255) NOT NULL,
`value` text,
@@ -12,4 +12,4 @@ CREATE TABLE `plugin_settings` (
);
--> statement-breakpoint
ALTER TABLE `plugin_settings` ADD CONSTRAINT `plugin_settings_plugin_id_plugins_id_fk` FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_plugin_settings_plugin_scope` ON `plugin_settings` (`plugin_id`,`scope`);
CREATE INDEX `idx_plugin_settings_plugin_scope` ON `plugin_settings` (`plugin_id`,`scope`);
@@ -0,0 +1 @@
ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','secret') NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -428,6 +428,13 @@
"when": 1790393789429,
"tag": "0060_mean_gorgon",
"breakpoints": true
},
{
"idx": 61,
"version": "5",
"when": 1790447309827,
"tag": "0061_plugin_settings_scope_enum",
"breakpoints": true
}
]
}
@@ -6,7 +6,8 @@ import {
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
export interface DfRow {
filesystem: string;
@@ -41,6 +41,35 @@ describe("parseDfLines", () => {
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
+14 -6
View File
@@ -142,7 +142,7 @@ function camelToSnake(value) {
return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
}
function keyTables(source, tables, keyed) {
function keyTables(source, tables, keyed, dialect) {
let out = "";
let cursor = 0;
for (const { table, index, end } of tables) {
@@ -150,10 +150,17 @@ function keyTables(source, tables, keyed) {
const columns = keyed.get(table);
out += source
.slice(index, end)
.replace(/\btext\("([a-z0-9_]+)"\)/g, (whole, col) =>
columns.has(col)
? `varchar("${col}", { length: ${KEY_LENGTH} })`
: whole,
.replace(
/\btext\("([a-z0-9_]+)"(?:,\s*\{\s*enum:\s*(\[[^\]]*\]),?\s*\})?\)/g,
(whole, col, values) => {
// Closed sets stay compact without shortening identifiers or keys.
if (values && dialect === "mysql")
return `mysqlEnum("${col}", ${values})`;
const options = values ? `, enum: ${values}` : "";
return columns.has(col)
? `varchar("${col}", { length: ${KEY_LENGTH}${options} })`
: whole;
},
);
cursor = end;
}
@@ -165,7 +172,7 @@ function transform(source, dialect) {
const isPg = dialect === "postgres";
// Key-bearing strings must be indexable. First, while the table offsets
// still match the source.
let out = keyTables(source, tables, keyed);
let out = keyTables(source, tables, keyed, dialect);
// Autoincrement primary keys, before the plain integer rule below.
out = out.replace(
@@ -235,6 +242,7 @@ function transform(source, dialect) {
]
: [
"mysqlTable",
"mysqlEnum",
"text",
"varchar",
"int",
+21
View File
@@ -141,6 +141,27 @@ describe("mysql output", () => {
expect(out).toContain('varchar("user_id", { length: 255 })');
expect(out).toContain('text("name")');
});
it("keeps closed scopes compact in four-column setting indexes", () => {
const source = `import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const pluginSettings = sqliteTable("plugin_settings", {
pluginId: text("plugin_id").notNull(),
scope: text("scope", { enum: ["admin", "user", "host", "secret"] }).notNull(),
scopeId: text("scope_id"),
key: text("key").notNull(),
}, (table) => [uniqueIndex("scope_key").on(table.pluginId, table.scope, table.scopeId, table.key)]);
`;
const mysql = transform(source, "mysql");
expect(mysql).toContain(
'mysqlEnum("scope", ["admin", "user", "host", "secret"])',
);
for (const name of ["plugin_id", "scope_id", "key"]) {
expect(mysql).toContain(`varchar("${name}", { length: 255 })`);
}
expect(transform(source, "postgres")).toContain(
'varchar("scope", { length: 255, enum: ["admin", "user", "host", "secret"] })',
);
});
});
describe("determinism", () => {
+2 -2
View File
@@ -9,6 +9,7 @@
import {
mysqlTable,
mysqlEnum,
text,
varchar,
int,
@@ -1083,8 +1084,7 @@ export const pluginSettings = mysqlTable(
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** admin | user | host */
scope: varchar("scope", { length: 255 }).notNull(),
scope: mysqlEnum("scope", ["admin", "user", "host", "secret"]).notNull(),
scopeId: varchar("scope_id", { length: 255 }),
key: varchar("key", { length: 255 }).notNull(),
/** JSON-encoded, so a field keeps its declared type across a round trip. */
+1 -2
View File
@@ -1084,8 +1084,7 @@ export const pluginSettings = pgTable(
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** admin | user | host */
scope: varchar("scope", { length: 255 }).notNull(),
scope: varchar("scope", { length: 255, enum: ["admin", "user", "host", "secret"] }).notNull(),
scopeId: varchar("scope_id", { length: 255 }),
key: varchar("key", { length: 255 }).notNull(),
/** JSON-encoded, so a field keeps its declared type across a round trip. */
+1 -2
View File
@@ -1082,8 +1082,7 @@ export const pluginSettings = sqliteTable(
pluginId: text("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** admin | user | host */
scope: text("scope").notNull(),
scope: text("scope", { enum: ["admin", "user", "host", "secret"] }).notNull(),
scopeId: text("scope_id"),
key: text("key").notNull(),
/** JSON-encoded, so a field keeps its declared type across a round trip. */
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import { readFileSync } from "node:fs";
import { TestSqliteDatabase, testDialect } from "./test-support.js";
import { PluginSettingsRepository } from "../../../database/repositories/plugin-settings-repository.js";
describe("PluginSettingsRepository", () => {
let adapter: TestSqliteDatabase;
let repo: PluginSettingsRepository;
const pluginId = "p".repeat(255);
beforeEach(async () => {
adapter = new TestSqliteDatabase();
repo = new PluginSettingsRepository(await adapter.connect());
await adapter.run(sql`
INSERT INTO plugins (id, name, version, manifest_json)
VALUES (${pluginId}, 'Settings test', '1.0.0', '{}')
`);
});
afterEach(async () => {
await adapter.close();
});
it("round-trips every supported scope and updates admin settings", async () => {
for (const scope of ["admin", "user", "host", "secret"] as const) {
const scopeId = scope === "admin" ? null : "42";
await repo.set(pluginId, scope, scopeId, "shared-key", scope);
expect(
await repo.get(pluginId, scope, scopeId, "shared-key"),
).toMatchObject({
scope,
value: scope,
});
}
await repo.set(pluginId, "admin", null, "shared-key", "updated");
expect(await repo.getAll(pluginId, "admin", null)).toHaveLength(1);
expect(await repo.get(pluginId, "admin", null, "shared-key")).toMatchObject(
{
value: "updated",
},
);
});
it("preserves full-length Unicode identifiers and distinct key suffixes", async () => {
const scopeId = "\u{1f511}".repeat(255);
const prefix = "\u{1f511}".repeat(254);
await repo.set(pluginId, "user", scopeId, `${prefix}a`, "first");
await repo.set(pluginId, "user", scopeId, `${prefix}b`, "second");
expect(await repo.getAll(pluginId, "user", scopeId)).toHaveLength(2);
expect(
await repo.get(pluginId, "user", scopeId, `${prefix}a`),
).toMatchObject({
value: "first",
});
expect(
await repo.get(pluginId, "user", scopeId, `${prefix}b`),
).toMatchObject({
value: "second",
});
});
it("still rejects duplicate non-null scope keys at the database boundary", async () => {
await repo.set(pluginId, "host", "42", "setting", "original");
await expect(
adapter.run(sql`
INSERT INTO plugin_settings (plugin_id, scope, scope_id, ${sql.identifier("key")}, value)
VALUES (${pluginId}, 'host', '42', 'setting', 'duplicate')
`),
).rejects.toThrow();
expect(await repo.get(pluginId, "host", "42", "setting")).toMatchObject({
value: "original",
});
});
it.runIf(testDialect() === "mysql")(
"preserves existing settings through the enum upgrade",
async () => {
const migrationDir = new URL(
"../../../../../drizzle/mysql/",
import.meta.url,
);
const create = readFileSync(
new URL("0029_open_captain_america.sql", migrationDir),
"utf8",
)
.split("--> statement-breakpoint")[0]
.replace("`plugin_settings`", "`plugin_settings_upgrade_test`")
.replace("enum('admin','user','host','secret')", "varchar(255)")
.replace(/\);\s*$/, ") DEFAULT CHARSET=utf8mb3;");
const upgrade = readFileSync(
new URL("0061_plugin_settings_scope_enum.sql", migrationDir),
"utf8",
).replace("`plugin_settings`", "`plugin_settings_upgrade_test`");
await adapter.run(sql.raw(create));
try {
for (const scope of ["admin", "user", "host", "secret"]) {
await adapter.run(sql`
INSERT INTO plugin_settings_upgrade_test (plugin_id, scope, scope_id, ${sql.identifier("key")}, value, encrypted)
VALUES (${pluginId}, ${scope}, ${scope === "admin" ? null : "42"}, 'setting', ${scope}, true)
`);
}
const before = await adapter.query(
sql`SELECT * FROM plugin_settings_upgrade_test ORDER BY id`,
);
await adapter.run(sql.raw(upgrade));
expect(
await adapter.query(
sql`SELECT * FROM plugin_settings_upgrade_test ORDER BY id`,
),
).toEqual(before);
} finally {
await adapter.run(sql`DROP TABLE plugin_settings_upgrade_test`);
}
},
);
});
+4 -1
View File
@@ -59,7 +59,7 @@ CREATE TABLE `rbac_known_permissions` (
CREATE TABLE `plugin_settings` (
`id` int AUTO_INCREMENT NOT NULL,
`plugin_id` varchar(255) NOT NULL,
`scope` varchar(255) NOT NULL,
`scope` enum('admin','user','host','secret') NOT NULL,
`scope_id` varchar(255),
`key` varchar(255) NOT NULL,
`value` text,
@@ -409,6 +409,9 @@ CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`);
-- ==== core 0060_mean_gorgon ====
DROP TABLE `sync_tombstones`;
-- ==== core 0061_plugin_settings_scope_enum ====
ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','secret') NOT NULL;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.