mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 13:21:47 +00:00
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:
1 parent
c38c2e84b8
commit
710f545e34
14 files changed
+4938
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user