From f779d6a08ec7619f15920ebd21b8dbb327465928 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 25 Sep 2026 09:58:45 -0500 Subject: [PATCH] test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught --- packages/plugin-sdk/ARCHITECTURE.md | 55 +- packages/plugin-sdk/FINISH-LIST.md | 8 +- .../session-recording/src/backend/files.ts | 39 + .../session-recording/src/backend/service.ts | 15 +- .../tests/backend/service.test.ts | 35 + scripts/build-upgrade-fixture.ts | 159 +++ scripts/lib/upgrade-sql.mjs | 124 ++ scripts/upgrade-check.compose.yml | 35 + scripts/upgrade-check.sh | 62 + scripts/upgrade-sql.test.ts | 44 + src/backend/boot.ts | 25 + src/backend/plugins/ctx-process.ts | 12 +- src/backend/plugins/ctx.ts | 5 +- src/backend/plugins/data.ts | 7 +- src/backend/plugins/paths.ts | 13 + src/backend/plugins/user-data.ts | 13 +- src/backend/starter.ts | 17 +- src/backend/tests/fixtures/upgrade/db.sqlite | Bin 0 -> 909312 bytes .../d4-user-admin/d4-session.cast | 1 + .../session_recordings/guacamole/d4-rdp.guac | 1 + .../tests/fixtures/upgrade/fixture.env | 4 + src/backend/tests/fixtures/upgrade/rows.ts | 844 ++++++++++++ .../tests/fixtures/upgrade/upgrade-mysql.sql | 1136 ++++++++++++++++ .../fixtures/upgrade/upgrade-postgres.sql | 1208 +++++++++++++++++ src/backend/tests/plugins/built-harness.ts | 106 +- .../tests/plugins/built-plugins-boot.test.ts | 4 +- src/backend/tests/plugins/remote-seed.ts | 146 ++ src/backend/tests/plugins/upgrade-2-8.test.ts | 268 ---- .../plugins/upgrade-fixture-data.test.ts | 72 + .../tests/plugins/upgrade-fixture.test.ts | 649 +++++++++ .../upgrade/opkssh-config-migration.test.ts | 2 +- .../session-recording-data-migration.test.ts | 157 +++ .../database-layer-preupgrade-backup.test.ts | 65 +- .../upgrade/opkssh-config-migration.ts | 4 +- src/backend/upgrade/plugin-data-moves.ts | 5 + .../session-recording-data-migration.ts | 167 +++ .../utils/database-layer-preupgrade-backup.ts | 54 +- 37 files changed, 5184 insertions(+), 377 deletions(-) create mode 100644 plugins/session-recording/src/backend/files.ts create mode 100644 scripts/build-upgrade-fixture.ts create mode 100644 scripts/lib/upgrade-sql.mjs create mode 100644 scripts/upgrade-check.compose.yml create mode 100755 scripts/upgrade-check.sh create mode 100644 scripts/upgrade-sql.test.ts create mode 100644 src/backend/tests/fixtures/upgrade/db.sqlite create mode 100644 src/backend/tests/fixtures/upgrade/files/session_logs/d4-user-admin/d4-session.cast create mode 100644 src/backend/tests/fixtures/upgrade/files/session_recordings/guacamole/d4-rdp.guac create mode 100644 src/backend/tests/fixtures/upgrade/fixture.env create mode 100644 src/backend/tests/fixtures/upgrade/rows.ts create mode 100644 src/backend/tests/fixtures/upgrade/upgrade-mysql.sql create mode 100644 src/backend/tests/fixtures/upgrade/upgrade-postgres.sql create mode 100644 src/backend/tests/plugins/remote-seed.ts delete mode 100644 src/backend/tests/plugins/upgrade-2-8.test.ts create mode 100644 src/backend/tests/plugins/upgrade-fixture-data.test.ts create mode 100644 src/backend/tests/plugins/upgrade-fixture.test.ts create mode 100644 src/backend/tests/upgrade/session-recording-data-migration.test.ts create mode 100644 src/backend/upgrade/session-recording-data-migration.ts diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 688b1f79e..223876b06 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -128,9 +128,10 @@ Plugins own their tables. A plugin declares them with `defineTable()` from `mysql` under `migrations//NNNN_name.sql`. Table names are prefixed `p__`, which `defineTable` adds. `ctx.kv` is for small key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a -per-plugin folder under `DATA_DIR`, created on first call, for state too -large for `ctx.kv` (session recordings, uploaded files); a plugin lays out -its own subdirectories underneath it. Core `schema.ts` ends up holding only +per-plugin folder, `/plugin-data/`, created on first call, for +state too large for `ctx.kv` (session recordings, uploaded files); a plugin +lays out its own subdirectories underneath it. It is kept apart from +`/plugins/`, where the loader looks for user plugins (**D4**). Core `schema.ts` ends up holding only core tables. **One table object, three DDL emitters.** The sqlite-core definitions already @@ -897,7 +898,10 @@ importing the old repository directly. **B14** added `enabledFor(hostId)` exists: remote desktop records an RDP or Telnet session only when the plugin is running and the host's switch is on, and writes the row as the session's user. Recording files live -under `ctx.files.dataDir()/session_logs//.cast`. Retention +under `ctx.files.dataDir()/session_logs//.cast`. **D4**: +`createFinished` moves a file written elsewhere (guacd's, in remote desktop's +recordings folder) to `ctx.files.dataDir()/session_recordings//`, +since playback and retention only read inside the data folder. Retention (`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours. **B15**'s docker plugin provides `docker.containers` (`listContainers`, @@ -1303,7 +1307,7 @@ base types and branches on no plugin type name. matches, else the one in the plugin's data folder, else a download pinned to the release's SHA-256. `opkssh login` runs through `ctx.process.run` and is killed on cancel, close and deactivate. The config lives at - `/plugins/opkssh/config.yml`; `redirect_uris` there are still the + `/plugin-data/opkssh/config.yml`; `redirect_uris` there are still the localhost listener candidates only, and the public callback still goes to OPKSSH as `--remote-redirect-uri`. - The chooser and callback pages are public plugin routes @@ -1743,6 +1747,43 @@ A host switch whose 2.8 column defaulted to on declares `enableDefault: true` (file manager, terminal), because a copy only writes the hosts that changed it, and every other host has to keep reading on. +**Before activate, not after.** A copy that sets how long a plugin keeps +history goes in `src/backend/upgrade/plugin-data-moves.ts` +(`runPluginDataMoves`), which the loader runs once a plugin's migrations +applied and before its `activate`. A plugin that prunes on activate would +otherwise prune by its own default first: session recording's retention +(and its 2.8 files) and host metrics' history retention move there. + +#### Proving it (D4) + +- **The fixture.** `src/backend/tests/fixtures/upgrade/` is a 2.8 install: + `db.sqlite` in 2.8's exact schema (`sqlite-2.8-schema.sql`) with rows in + every table `LEGACY_TABLE_OWNERS` lists, every host flag and 2.8 auth type, + TOTP, passkey, SSO and LDAP users, custom roles and the moved settings keys; + `fixture.env` with the keys it was encrypted with; and `files/`, the + recording files its rows point at. `rows.ts` is the source, and + `npx tsx scripts/build-upgrade-fixture.ts` rewrites the rest, encrypting + the columns 2.8 encrypted the way 2.8 did. `upgrade-fixture-data.test.ts` + fails when a legacy table has no rows. +- **The test.** `src/backend/tests/plugins/upgrade-fixture.test.ts` boots it + through `bootCore` (the starter's order: backup, database, core migrations, + plugins, data copies) against `dist/plugins`, checks every row, setting, + secret, role, login and sync type through plugin routes, settings and ctx, + then boots the same data directory again and checks that nothing changed. +- **Postgres and MySQL.** `scripts/lib/upgrade-sql.mjs` prints every statement + the upgrade runs on each engine, snapshotted in + `fixtures/upgrade/upgrade-{postgres,mysql}.sql` by `scripts/upgrade-sql.test.ts`, + which also refuses a `DROP TABLE` of a legacy table or any `DROP COLUMN`. + `bash scripts/upgrade-check.sh` starts both servers in Docker, builds the + 2.8 schema from the tag's own drizzle migrations, copies the fixture in and + runs the same test with `TEST_DIALECT` set. +- **The backup.** `backupBeforeUpgrade` (`src/backend/boot.ts`) copies the + SQLite file before anything opens it. Each upgrade that rewrites the + database has its own marker (`ensurePreupgradeBackup({ reason })`); 2.9.0's + is `pre-plugin-runtime`, so an install that already has the 2.5 refactor's + marker still gets a copy. The test checks the copy is the 2.8 file byte for + byte and that a second boot makes no new one. + **Left for 3.0.0.** These stay in 2.9.0 so a downgrade still works, and go once every install has booted it: @@ -2050,7 +2091,7 @@ authority for signatures. | `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin | | `events.emit/on` | `events:core` for core topics | Topics under `plugin..*`, or core's (`user.deleted`, `user.data_wiped`, host events) | | `kv.get/set/delete/list` | `kv:own` | Small key/value state | -| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` | +| `files.dataDir()` | `files:own` | A per-plugin folder, `/plugin-data/` | | `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk | | `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it | | `registry.provide/consume/revoke` | none | The low-level named registry services sit on | @@ -2294,7 +2335,7 @@ keeps its account key sealed in `ctx.kv`, and checks every 12 hours. - `ensureBinary({ name, version, url, sha256, prebuilt })` returns the path of a verified executable: the first `prebuilt` path whose SHA-256 matches (a copy baked into the Docker image, used in place), else - `/plugins//bin/` when it matches, else a download over + `/plugin-data//bin/` when it matches, else a download over https (redirects followed, `network:outbound` checked only then), written atomically with mode 755. A mismatch throws and keeps nothing. diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index 69f67845a..4f60a8754 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -117,11 +117,9 @@ with a real install, a real device or a repo outside this one. URL (`/plugin-api/termix-identity/u/`, the old one still 308s) and the HTTPS certificate page (`docs.termix.site/features/networking/ssl`, which still describes certbot and the webroot challenge). -- Upgrade a real 2.8 install on Postgres and on MySQL (SQLite is covered by - the upgrade test): proxmox host settings, tunnels, web endpoints, the file - manager's default path and SCP switch, and the terminal, tmux, sharing and - recording switches all survive. Postgres was checked by hand against a - scratch database; MySQL only runs in CI. +- Run `bash scripts/upgrade-check.sh` once with Docker running. It upgrades + the D4 fixture on real Postgres 16 and MySQL 8. The Postgres half passed + against an in-process PGlite server during D4; MySQL has not run yet. - Host settings round trip: filter the Hosts panel by a plugin feature, bulk enable and disable a plugin on a few hosts, export hosts to JSON and import them on another install (plugin settings come across, secrets do not), the diff --git a/plugins/session-recording/src/backend/files.ts b/plugins/session-recording/src/backend/files.ts new file mode 100644 index 000000000..32da411a8 --- /dev/null +++ b/plugins/session-recording/src/backend/files.ts @@ -0,0 +1,39 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export function isInside(file: string, dir: string): boolean { + return path.resolve(file).startsWith(`${path.resolve(dir)}${path.sep}`); +} + +/** + * Moves a finished recording someone else wrote (guacd's, in remote + * desktop's recordings folder) under this plugin's data folder, which is the + * only place playback and retention read from. Returns where it ended up; + * a file that is not there is left alone. + */ +export async function adoptRecordingFile( + source: string, + dataDir: string, +): Promise { + if (isInside(source, dataDir)) return source; + const target = path.join( + dataDir, + "session_recordings", + path.basename(path.dirname(source)), + path.basename(source), + ); + try { + await fs.access(source); + } catch { + return source; + } + await fs.mkdir(path.dirname(target), { recursive: true }); + try { + await fs.rename(source, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error; + await fs.copyFile(source, target); + await fs.unlink(source); + } + return target; +} diff --git a/plugins/session-recording/src/backend/service.ts b/plugins/session-recording/src/backend/service.ts index b75002a85..73e313e72 100644 --- a/plugins/session-recording/src/backend/service.ts +++ b/plugins/session-recording/src/backend/service.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { PluginContext } from "@termix/plugin-sdk/backend"; import type { SessionRecordingRepository } from "./repository.js"; +import { adoptRecordingFile } from "./files.js"; /** * recordings.writer v1, the service ssh-terminal calls to record sessions. @@ -40,8 +41,8 @@ export interface RecordingsWriterV1 { /** * Inserts a row for a recording a caller already wrote to disk itself * (remote desktop's guacd recordings), rather than streaming through - * open()/append(). The caller already decided to record; this only writes - * the row. + * open()/append(). The caller already decided to record. The file is + * moved under this plugin's data folder, where playback reads it. */ createFinished: (input: { hostId: number; @@ -117,7 +118,15 @@ export function createRecordingsWriter( async createFinished(input) { const username = await repository.usernameFor(input.userId); - const row = await repository.create({ ...input, username }); + const recordingPath = await adoptRecordingFile( + input.recordingPath, + await ctx.files.dataDir(), + ).catch(() => input.recordingPath); + const row = await repository.create({ + ...input, + recordingPath, + username, + }); return { id: row.id }; }, }; diff --git a/plugins/session-recording/tests/backend/service.test.ts b/plugins/session-recording/tests/backend/service.test.ts index 094b10ddf..f76a9087b 100644 --- a/plugins/session-recording/tests/backend/service.test.ts +++ b/plugins/session-recording/tests/backend/service.test.ts @@ -172,6 +172,41 @@ describe("recordings.writer", () => { expect(row.id).toBeGreaterThan(0); }); + it("createFinished moves guacd's file under the data folder, where playback reads it", async () => { + const { mock, writer } = await setup(); + const guacdDir = await fs.mkdtemp(path.join(os.tmpdir(), "guacd-")); + const source = path.join(guacdDir, "guacamole", "rdp-1.guac"); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "4.size,1.0;"); + + const row = await writer.createFinished({ + hostId: 7, + userId: "user-1", + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + duration: 10, + recordingPath: source, + protocol: "rdp", + format: "guacamole", + }); + + const target = path.join( + await mock.ctx.files.dataDir(), + "session_recordings", + "guacamole", + "rdp-1.guac", + ); + await expect(fs.readFile(target, "utf8")).resolves.toBe("4.size,1.0;"); + await expect(fs.access(source)).rejects.toThrow(); + const stored = db!.sqlite + .prepare( + "SELECT recording_path FROM p_session_recording_session_recordings WHERE id = ?", + ) + .get(row.id) as { recording_path: string }; + expect(stored.recording_path).toBe(target); + await fs.rm(guacdDir, { recursive: true, force: true }); + }); + it("enabledFor follows the host's recording switch", async () => { const { mock, writer } = await setup(); await mock.ctx.settings.setHost(7, "enableSessionRecording", true); diff --git a/scripts/build-upgrade-fixture.ts b/scripts/build-upgrade-fixture.ts new file mode 100644 index 000000000..123367963 --- /dev/null +++ b/scripts/build-upgrade-fixture.ts @@ -0,0 +1,159 @@ +/** + * Writes src/backend/tests/fixtures/upgrade/{db.sqlite,fixture.env,files/}: a 2.8 install's + * data directory with every feature table filled. The schema is 2.8's own + * dump, the rows come from rows.ts, and the columns 2.8 encrypted are + * encrypted the way 2.8 did it (a v3 system-wrapped data key per user, then + * FieldCrypto per field), so the upgrade test has to really decrypt them. + * + * Usage: npx tsx scripts/build-upgrade-fixture.ts + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import bcrypt from "bcryptjs"; +import { FieldCrypto } from "../src/backend/utils/field-crypto.js"; +import { + ENCRYPTED_COLUMNS, + FIXTURE_PASSWORD, + RECORDING_FILES, + ROWS, + type Row, +} from "../src/backend/tests/fixtures/upgrade/rows.js"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const FIXTURES = path.join(ROOT, "src/backend/tests/fixtures"); +const OUT = path.join(FIXTURES, "upgrade"); + +const hex = (label: string) => + crypto + .createHash("sha256") + .update(`termix-d4-fixture:${label}`) + .digest("hex"); + +const ENV = { + JWT_SECRET: hex("jwt") + hex("jwt-2"), + DATABASE_KEY: hex("database"), + ENCRYPTION_KEY: hex("encryption"), + INTERNAL_AUTH_TOKEN: hex("internal"), +}; +const masterKey = Buffer.from(ENV.ENCRYPTION_KEY, "hex"); + +/** UserKeyManager's v3 wrap, unchanged since 2.8. */ +function wrapDek(userId: string, dek: Buffer): string { + const wrapKey = Buffer.from( + crypto.hkdfSync( + "sha256", + masterKey, + Buffer.alloc(0), + `termix:dek-wrap:v3:${userId}`, + 32, + ), + ); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", wrapKey, iv); + cipher.setAAD(Buffer.from(userId, "utf8")); + const ct = Buffer.concat([cipher.update(dek), cipher.final()]); + return JSON.stringify({ + v: 3, + alg: "aes-256-gcm", + iv: iv.toString("base64"), + ct: ct.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + createdAt: "2026-05-01T10:00:00.000Z", + }); +} + +const deks = new Map(); +const dekFor = (userId: string) => { + let dek = deks.get(userId); + if (!dek) { + dek = Buffer.from(hex(`dek:${userId}`), "hex"); + deks.set(userId, dek); + } + return dek; +}; + +function prepareRow(table: string, row: Row): Row { + const out: Row = { ...row }; + if (table === "users" && out.password_hash === undefined) { + out.password_hash = bcrypt.hashSync(FIXTURE_PASSWORD, 10); + } + for (const [encTable, column, field, userColumn] of ENCRYPTED_COLUMNS) { + if (encTable !== table || typeof out[column] !== "string" || !out[column]) + continue; + const userId = String(out[userColumn]); + const recordId = table === "users" ? userId : String(out.id); + out[column] = FieldCrypto.encryptField( + out[column] as string, + dekFor(userId), + recordId, + field, + ); + } + return out; +} + +function insert(sqlite: Database.Database, table: string, row: Row): void { + const keys = Object.keys(row); + sqlite + .prepare( + `INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`, + ) + .run(...keys.map((key) => row[key])); +} + +fs.mkdirSync(OUT, { recursive: true }); +const dbPath = path.join(OUT, "db.sqlite"); +fs.rmSync(dbPath, { force: true }); + +const sqlite = new Database(dbPath); +sqlite.pragma("journal_mode = DELETE"); +sqlite.exec( + fs.readFileSync(path.join(FIXTURES, "sqlite-2.8-schema.sql"), "utf8"), +); +sqlite.pragma("foreign_keys = ON"); + +sqlite.transaction(() => { + for (const [table, rows] of Object.entries(ROWS)) { + for (const row of rows) insert(sqlite, table, prepareRow(table, row)); + } + for (const user of ROWS.users) { + const userId = String(user.id); + insert(sqlite, "settings", { + key: `user_dek_v3_${userId}`, + value: wrapDek(userId, dekFor(userId)), + }); + } +})(); + +const violations = sqlite.pragma("foreign_key_check") as unknown[]; +if (violations.length > 0) { + throw new Error(`Foreign key violations: ${JSON.stringify(violations)}`); +} +sqlite.exec("VACUUM"); +sqlite.close(); + +fs.writeFileSync( + path.join(OUT, "fixture.env"), + Object.entries(ENV) + .map(([key, value]) => `${key}=${value}`) + .join("\n") + "\n", +); + +// The recording files 2.8 left in DATA_DIR, which the rows point at. +const files = path.join(OUT, "files"); +fs.rmSync(files, { recursive: true, force: true }); +for (const [kind, relative] of Object.entries(RECORDING_FILES)) { + const file = path.join(files, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `d4 ${kind} recording +`, + ); +} + +console.log(`Wrote ${path.relative(ROOT, dbPath)} and fixture.env`); diff --git a/scripts/lib/upgrade-sql.mjs b/scripts/lib/upgrade-sql.mjs new file mode 100644 index 000000000..9da218bdd --- /dev/null +++ b/scripts/lib/upgrade-sql.mjs @@ -0,0 +1,124 @@ +/** + * Every SQL statement a 2.8 install's Postgres or MySQL database runs on its + * way to 2.9.0, in the order it runs them: core's drizzle migrations added + * since release-2.8.0-tag, then each bundled plugin's own migrations in the + * order the loader activates them. The data copies that run after that + * (src/backend/upgrade/) are TypeScript, not SQL, and are covered by the + * upgrade test and scripts/upgrade-check.sh. + * + * Snapshot-tested by scripts/upgrade-sql.test.ts, so any change to the + * upgrade path shows up as a diff in review. + * + * Usage: node scripts/lib/upgrade-sql.mjs postgres|mysql + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +/** The last core migration release-2.8.0-tag shipped, per dialect. */ +export const LAST_28_MIGRATION = { + postgres: "0024_flimsy_forge", + mysql: "0023_even_beast", +}; + +/** Plugin ids in the order the loader activates them: dependencies first. */ +export function pluginActivationOrder(root = ROOT) { + const dir = path.join(root, "plugins"); + const manifests = new Map( + fs + .readdirSync(dir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.existsSync(path.join(dir, entry.name, "manifest.json")), + ) + .map((entry) => [ + entry.name, + JSON.parse( + fs.readFileSync(path.join(dir, entry.name, "manifest.json"), "utf8"), + ), + ]) + .sort(([a], [b]) => a.localeCompare(b)), + ); + + const order = []; + const done = new Set(); + const visit = (id) => { + if (done.has(id) || !manifests.has(id)) return; + done.add(id); + const manifest = manifests.get(id); + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + visit(dependency); + } + for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) { + visit(dependency); + } + order.push(id); + }; + for (const id of manifests.keys()) visit(id); + return order; +} + +function section(title, sql) { + return `-- ==== ${title} ====\n${sql.trim()}\n`; +} + +export function buildUpgradeSql(dialect, root = ROOT) { + if (!(dialect in LAST_28_MIGRATION)) { + throw new Error(`unsupported dialect "${dialect}"`); + } + const parts = [ + `-- Termix 2.8.0 to 2.9.0 on ${dialect}, generated by scripts/lib/upgrade-sql.mjs\n`, + ]; + + const coreDir = path.join(root, "drizzle", dialect); + const journal = JSON.parse( + fs.readFileSync(path.join(coreDir, "meta", "_journal.json"), "utf8"), + ); + const tags = journal.entries.map((entry) => entry.tag); + const last = tags.indexOf(LAST_28_MIGRATION[dialect]); + if (last === -1) { + throw new Error( + `${LAST_28_MIGRATION[dialect]} is not in the ${dialect} journal`, + ); + } + for (const tag of tags.slice(last + 1)) { + parts.push( + section( + `core ${tag}`, + fs.readFileSync(path.join(coreDir, `${tag}.sql`), "utf8"), + ), + ); + } + + for (const id of pluginActivationOrder(root)) { + const dir = path.join(root, "plugins", id, "migrations", dialect); + if (!fs.existsSync(dir)) continue; + for (const file of fs + .readdirSync(dir) + .filter((name) => name.endsWith(".sql")) + .sort()) { + parts.push( + section( + `plugin ${id} ${file}`, + fs.readFileSync(path.join(dir, file), "utf8"), + ), + ); + } + } + + return parts.join("\n").replace(/\r\n/g, "\n"); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + process.stdout.write(buildUpgradeSql(process.argv[2] ?? "postgres")); +} diff --git a/scripts/upgrade-check.compose.yml b/scripts/upgrade-check.compose.yml new file mode 100644 index 000000000..b111c5e77 --- /dev/null +++ b/scripts/upgrade-check.compose.yml @@ -0,0 +1,35 @@ +# Throwaway Postgres and MySQL servers for scripts/upgrade-check.sh. Data +# lives in tmpfs, so every run starts from an empty server. +name: termix-upgrade-check + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: termix + POSTGRES_PASSWORD: termix + POSTGRES_DB: termix + ports: + - "55433:5432" + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U termix -d termix"] + interval: 2s + timeout: 5s + retries: 30 + + mysql: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: termix + MYSQL_DATABASE: termix + ports: + - "33307:3306" + tmpfs: + - /var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ptermix"] + interval: 2s + timeout: 5s + retries: 60 diff --git a/scripts/upgrade-check.sh b/scripts/upgrade-check.sh new file mode 100755 index 000000000..60fc6048e --- /dev/null +++ b/scripts/upgrade-check.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Upgrades the 2.8 fixture install to 2.9.0 on real Postgres and MySQL +# servers and runs the same checks the SQLite upgrade test runs. +# +# Needs Docker and a built tree (npm run build). Run from anywhere: +# bash scripts/upgrade-check.sh # both engines +# bash scripts/upgrade-check.sh postgres # one engine +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE=(docker compose -f "$ROOT/scripts/upgrade-check.compose.yml") +TAG="release-2.8.0-tag" +if [ "$#" -gt 0 ]; then + DIALECTS=("$@") +else + DIALECTS=(postgres mysql) +fi + +cd "$ROOT" + +if [ ! -f dist/plugins/snippets/dist/backend.js ]; then + echo "dist/plugins is missing. Run npm run build first." >&2 + exit 1 +fi +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG is missing. Run git fetch --tags first." >&2 + exit 1 +fi + +LEGACY="$(mktemp -d)" +cleanup() { + "${COMPOSE[@]}" down -v >/dev/null 2>&1 || true + rm -rf "$LEGACY" +} +trap cleanup EXIT + +# 2.8's own drizzle migrations build the 2.8 schema on each server. +git archive "$TAG" drizzle | tar -x -C "$LEGACY" + +"${COMPOSE[@]}" up -d --wait + +status=0 +for dialect in "${DIALECTS[@]}"; do + case "$dialect" in + postgres) url="postgres://termix:termix@127.0.0.1:55433/termix" ;; + mysql) url="mysql://root:termix@127.0.0.1:33307/termix" ;; + *) + echo "Unknown dialect $dialect (postgres or mysql)" >&2 + exit 2 + ;; + esac + echo + echo "== 2.8 to 2.9.0 on $dialect" + if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$url" \ + UPGRADE_LEGACY_MIGRATIONS_DIR="$LEGACY/drizzle" \ + npx vitest run src/backend/tests/plugins/upgrade-fixture.test.ts \ + --project backend; then + status=1 + fi +done + +exit "$status" diff --git a/scripts/upgrade-sql.test.ts b/scripts/upgrade-sql.test.ts new file mode 100644 index 000000000..65d68d626 --- /dev/null +++ b/scripts/upgrade-sql.test.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { LEGACY_TABLE_OWNERS } from "../packages/plugin-sdk/src/db"; +import { buildUpgradeSql } from "./lib/upgrade-sql.mjs"; + +const SNAPSHOTS = path.resolve( + __dirname, + "../src/backend/tests/fixtures/upgrade", +); + +const RENAME = { + postgres: /^ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)";$/gm, + mysql: /^RENAME TABLE `([a-z0-9_]+)` TO `(p_[a-z0-9_]+)`;$/gm, +}; + +describe.each(["postgres", "mysql"] as const)( + "the 2.8 to 2.9.0 upgrade SQL on %s", + (dialect) => { + const sql = buildUpgradeSql(dialect); + const statements = sql.replace(/^--.*$/gm, ""); + + it("matches the reviewed snapshot", async () => { + await expect(sql).toMatchFileSnapshot( + path.join(SNAPSHOTS, `upgrade-${dialect}.sql`), + ); + }); + + it("adopts every legacy table by renaming it, never by dropping it", () => { + const renamed = new Set( + [...sql.matchAll(RENAME[dialect])].map((match) => match[1]), + ); + for (const table of Object.keys(LEGACY_TABLE_OWNERS)) { + expect(renamed, table).toContain(table); + expect(statements).not.toMatch( + new RegExp(`DROP TABLE[^;]*[\`"]${table}[\`"]`, "i"), + ); + } + }); + + it("never drops a column", () => { + expect(statements).not.toMatch(/DROP COLUMN/i); + }); + }, +); diff --git a/src/backend/boot.ts b/src/backend/boot.ts index e72c54315..e9aca5fb7 100644 --- a/src/backend/boot.ts +++ b/src/backend/boot.ts @@ -6,6 +6,31 @@ import { AuthManager } from "./utils/auth-manager.js"; import { DataCrypto } from "./utils/data-crypto.js"; +import { ensurePreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js"; +import { systemLogger } from "./utils/logger.js"; + +/** + * Copies the SQLite file before anything opens it, so the copy is the database + * exactly as the previous version left it: before core's schema patches and + * before any plugin adopts a table. Client-server engines are the operator's + * to back up. + */ +export async function backupBeforeUpgrade(options: { + dataDir: string; + version: string; +}): Promise { + const { needsExplicitPersist, resolveDatabaseDialect } = + await import("./database/db/dialect.js"); + const dialect = resolveDatabaseDialect(); + if (needsExplicitPersist(dialect)) { + ensurePreupgradeBackup(options); + return; + } + systemLogger.info( + `Skipping pre-upgrade backup on ${dialect} - back up the database yourself`, + { operation: "backend_init_db_backup_skipped", dialect }, + ); +} export async function runCoreBootMigrations(): Promise { const { UserKeyManager } = await import("./utils/user-keys.js"); diff --git a/src/backend/plugins/ctx-process.ts b/src/backend/plugins/ctx-process.ts index 02351ebf8..b2792a5c4 100644 --- a/src/backend/plugins/ctx-process.ts +++ b/src/backend/plugins/ctx-process.ts @@ -19,6 +19,7 @@ import type { } from "@termix/plugin-sdk/backend"; import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import { assertCapability } from "./permissions.js"; +import { getPluginDataDir } from "./paths.js"; import type { DisposableBag } from "./disposables.js"; type AuditFn = ( @@ -31,7 +32,7 @@ interface Deps { manifest: PluginManifest; bag: DisposableBag; audit: AuditFn; - /** Where downloaded binaries go. Defaults to /plugins//bin. */ + /** Where downloaded binaries go. Defaults to /plugin-data//bin. */ binDir?: () => string; /** Test seam for the download. */ fetch?: typeof fetch; @@ -58,14 +59,7 @@ export function createPluginProcess(deps: Deps): PluginProcess { const pluginId = deps.manifest.id; const declared = deps.manifest.capabilities; const binDir = - deps.binDir ?? - (() => - path.join( - process.env.DATA_DIR ?? "./db/data", - "plugins", - pluginId, - "bin", - )); + deps.binDir ?? (() => path.join(getPluginDataDir(pluginId), "bin")); const doFetch = deps.fetch ?? fetch; const audited = async ( diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 55aaeb470..c84c88dee 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -300,9 +300,8 @@ export function createPluginContext( "files:own", async () => { const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const base = process.env.DATA_DIR ?? "./db/data"; - const dir = path.join(base, "plugins", pluginId); + const { getPluginDataDir } = await import("./paths.js"); + const dir = getPluginDataDir(pluginId); await fs.mkdir(dir, { recursive: true }); return dir; }, diff --git a/src/backend/plugins/data.ts b/src/backend/plugins/data.ts index 91867c3f8..e5f9e367c 100644 --- a/src/backend/plugins/data.ts +++ b/src/backend/plugins/data.ts @@ -78,10 +78,11 @@ export function resetPluginData(): void { } async function execute(statements: string[]): Promise { - const { getDb } = await import("../database/db/index.js"); - const db = getDb(); + // getDb() has run() only on SQLite; runStatement picks execute() elsewhere. + const { runStatement } = + await import("../utils/crypto-migration/raw-rows.js"); for (const statement of statements) { - await db.run(sql.raw(statement)); + await runStatement(sql.raw(statement)); } } diff --git a/src/backend/plugins/paths.ts b/src/backend/plugins/paths.ts index dfa25c099..5b63a2bdf 100644 --- a/src/backend/plugins/paths.ts +++ b/src/backend/plugins/paths.ts @@ -15,6 +15,19 @@ export function getPluginsDir(): string { return path.join(process.env.DATA_DIR || "./db/data", "plugins"); } +/** + * A plugin's own files (ctx.files.dataDir(), downloaded binaries). Kept out of + * getPluginsDir(), where the loader would read every data folder as a user + * plugin that clashes with the bundled one of the same name. + */ +export function getPluginDataDir(pluginId: string): string { + return path.join( + process.env.DATA_DIR || "./db/data", + "plugin-data", + pluginId, + ); +} + /** * Where user .tmxplug files are unpacked. A dot directory inside the user * plugins dir, which the loader's directory scan skips. diff --git a/src/backend/plugins/user-data.ts b/src/backend/plugins/user-data.ts index d41114029..691f651f1 100644 --- a/src/backend/plugins/user-data.ts +++ b/src/backend/plugins/user-data.ts @@ -10,7 +10,10 @@ import { sql } from "drizzle-orm"; import { columnName, createTableSql } from "@termix/plugin-sdk/ddl"; import { prefixedTableName } from "@termix/plugin-sdk/db"; import type { PluginTableDefinition } from "@termix/plugin-sdk/db"; -import { getDb } from "../database/db/index.js"; +import { + runStatement, + selectRows, +} from "../utils/crypto-migration/raw-rows.js"; import { listAllTables } from "./data.js"; export interface UserOwnedTable { @@ -56,11 +59,10 @@ type Row = Record; export async function readUserPluginRows( userId: string, ): Promise> { - const db = getDb(); const result: Record = {}; for (const owned of listUserOwnedTables()) { try { - const rows = await db.all( + const rows = await selectRows( sql`SELECT ${sql.join( owned.columns.map((column) => sql.identifier(column)), sql`, `, @@ -137,7 +139,6 @@ export async function importUserPluginRows( importDb: SqliteLike, userId: string, ): Promise { - const db = getDb(); const summary: PluginRowsImport = { imported: 0, skipped: 0, errors: [] }; for (const owned of listUserOwnedTables()) { @@ -160,7 +161,7 @@ export async function importUserPluginRows( column === owned.userColumn ? userId : source[column], ); try { - const existing = await db.all( + const existing = await selectRows( sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join( columns.map((column, index) => values[index] === null @@ -174,7 +175,7 @@ export async function importUserPluginRows( summary.skipped++; continue; } - await db.run( + await runStatement( sql`INSERT INTO ${sql.identifier(owned.table)} (${sql.join( columns.map((column) => sql.identifier(column)), sql`, `, diff --git a/src/backend/starter.ts b/src/backend/starter.ts index e9cfb5804..487a9a79f 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -4,7 +4,6 @@ import { promises as fs, readFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { AutoSSLSetup } from "./tls/self-signed.js"; -import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js"; import { DatabaseSaveTrigger } from "./utils/database-save-trigger.js"; import { SystemCrypto } from "./utils/system-crypto.js"; import { @@ -160,20 +159,8 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { await import("./database/db/dialect.js"); const databaseDialect = resolveDatabaseDialect(); - // The pre-upgrade backup copies the SQLite file, so there is nothing for it - // to do on a client-server engine. Say so rather than no-op silently: - // backups are the operator's own responsibility there. - if (needsExplicitPersist(databaseDialect)) { - ensureDatabaseLayerPreupgradeBackup({ dataDir, version }); - } else { - systemLogger.info( - `Skipping pre-upgrade backup on ${databaseDialect} - back up the database yourself`, - { - operation: "backend_init_db_backup_skipped", - dialect: databaseDialect, - }, - ); - } + const { backupBeforeUpgrade } = await import("./boot.js"); + await backupBeforeUpgrade({ dataDir, version }); await AutoSSLSetup.initialize(); systemLogger.success("SSL setup completed", { diff --git a/src/backend/tests/fixtures/upgrade/db.sqlite b/src/backend/tests/fixtures/upgrade/db.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..28509508fb71e58369d225cec9533e1a7fb2670f GIT binary patch literal 909312 zcmeFadypL0df3;q3+{_p3`kyZhzmk&4leFuiCt{Z^gNdr;vFs;;1c@)c4sdMQdH|q z_nF=1&P)%xd-fq3QQ<}MUfoFfBZ>adqY|sil1j2m<#HrhQ7ox^UDAzgsT?_uOR<#L zDajR;T#h3tjxALx-|2VHOz#5#2@w2-!^QM@^*O)qJKuSBpVjLNnnn4#ZZwI-i`;Q8 zk>I}0^Bl+h9Q^-B@W1!@S@?0pyMTWaq2F;oe~!EGU;X2!!1s9a@31i6N&Z#x|4IJ) zi38&j}Ql*caJ-=INo--01|TwN$!ee$W7+ z$)scH_O(pfn!HJOO*!NJI(m0|bU1b9OyZk)i_{vFG^k<8MyEl|(9f~C6{%8{_-f_# z1&I%N@aM*O|D&n={8ClABCYV(R^}HgD>wM7(ha_{R$ZQ7f)o~|rRr2De8;4Q43VnR z+f{yP8U9~eSm0Nr%hHOpG$*a{EP#1VQz!Z5CH|7MAVC)9DywsqOHw$U4c)X@I^NAg zDVXMltP+dtpGb=|X*4I{ThJDR6Wqej?Ir1QWo@C#XTqT^Lt9^mvRS+BJ<_x`3~FxZ z4OQMEjShu^R~90v`2K3gAeN@LWRog-OEtZchC?elq@-`R-bYVh*WPNc;EKDz_y2rPe<*nNJv?r9}>D6hK}0^>%85aKH@0h>5vk(b+Br#5<0)1>Z62?&jTqTG_g?8Ovf09 zx)q)k3HMMS`{CdR=&U>}qqzw^x_H$!JIy9B98(b%U7PGSbkY?Ox>imnX5zz}5$j=E z#Oj#Qy99yTw59ggn|8aQ^|yLp5t;JGAc4?_Ohz)l zEroVn4o3KQd7fh}@f5_Iyz`~RaB6xwaqnHP?nf$EcZC~>q}uKKK%#wEHPzBASBhck zeiIc~n6P*MHsYY70vr0bZcV}8Pi{Q}fB%LBfAsg^kNN}fr}ho_^8;(}=bh{DXK?}k z%+0}{Vj2ESv3TjPz@OA9_;c)8_%l8>4u2ELzsJEBKS%%xAOR$R1dsp{Kmter2_OL^ zfCP}hV?^N1#0WQWev}hMH8;&>Gp30OtAlV6o>IvQ*}}9?p3dg^Ol~$;oXwT`II;1+ z`1Egc$-hkgaq^!ge>3@alK)NezewIpD#_(!C3zuvCVBkWUmyFkW50LomyZ3-W8XaX z!Lj#_y>Tpmj6Zf{{Ljb#)%bVE|GV*@8voD7cg8oy-x{xspBsPX=wBTD!=wM`=r0`o zk)xfX-#B{l=-H#=PygRf{}J2UlIprMy+ON2lf)0 zCfieJk#+Og?BMxP%OEYY4%-$Ds=%`H$;@DRw64L1xF%`A#w0_A&5DMlbS(3EVNi^E zwKdqVr#?T^?d~L%x-cjv6uki(MPx(Qo3JL|ticwGfz#82l~FGv<_0mS+$M&lDO#Jf zEHgDVm>-3O(w(NhBe!7JjJ%sbxO^cOH`pc)bKv-k zgA=2Adz0;$we(HeGLxs^E?f4FCCA~*UmkpQR3+v{O(%v53S?|)3Y8mLYtwx0B;<2L zZ_+lXirgT(I_SwWFAPowj<6NEX+D>Na4l+W>&7PBr>!^i_1$NmhkV-0?abM16WBcP z{0Wd1+qn$;&6%2dyCPFx0>B}6qXXMwE!sApeGU?ddYGwaqk&BjtjhK_pLzyF8w%|0 z*k!szj~!<+@NaECn}n3>4N75`F>DQLF%5j?*x*FMar2aUbbRon6U=<(C=}dmY3(+| zAqo`7Jo@zD>4fWK4m>*sNqPb5uyYq`!waK>#nHYyL7y3co0+E0)Wi|^z_DS-qpiwr z@t zXksvxuz$Mi|Nopz{_}@mgfIytfCP{L5Oha4s>-jh=X! z6WO32_64}JPBH8MiR9np;ENw5fCP{L5!n;4;;7kTElWyyp)877 z_zI<5L4a`Z%nbyri}hk5C*%qRT30h_UCGu;8 zVEU%HIj!c_+ZO!oGuZFrGzOAQB>xW%zW6}`NB{{S0VIF~kN^@u0!RP}AOR$R1U|n6 zUOX~!W)vnrB5w-x-Z;SC{|ATu;RgvI0VIF~kN^@u0!RP}AOR$R1dsp{c!CMw`2PtO zE*1?5AOR$R1dsp{Kmter2_OL^fCP|$O903Js0v5`2_OL^fCP{L51gxtaVqO`YVImmq}&2}Chh zS)Hp~l5EJ6ceh7}Q)kX3zL|H#*Pw;wLM|td@$X=?%^Kd>0=yp)Py@bN^ zmZ7b$gW_7d?O64MP#d5e8+t>Pw@9Nyq2QH}YKQ&RjzKI7zgSNW2G7a1db|=mvV+d$XSE;aBa++ZRWMQ=*u-`*N4&$;t+4wP+)x zfxgpdj3)X49!MjZ-iI~G7jsCZLe*k_t#V7UUz#o5((0Px2-z!r|I`D1cUOjoQxg-3 zPm~b9x@Kstb(frbG17;b>-9ji`Wol^sJ{=LdbdIF#2D6lJIvmd9vB@;wZq9n)z%wZ zLG-X+|LWePrb*WKuoouXqK0Pe#xz6|+r(&r>?gvvBFbh<-v-OIzL)V50;MVr4W+K0 zO>kOEr90*a4b7r5=~%jbEfY=lUJPu?nef%{-O5wLsk3JjpGb~2hC+tJ4MlY^6zZ{1 zMXz-GnC=Ft(MD7bVd^zvQn_O|A;KPR_s5Re#~tAMeUY%YO6#Q4u;iw$y0;Dc9;C zcr#)(Op90@GkTXGuzOf|BnD}>8(M#B2bPfOZd}d|uaAMg+}<9v`>yxgzN;p?MwF?G z_wz`%ws;UaG|!@~xBvAx{zsvI+Yk{XA=H#6(C5BVe(}{cUdUZci!MZElKqS>}-v<)y!>Xy4 zX1P)fQ}z+JhmH#8-- zr*3X)?TD3bk{y_H=^5Yq`STlJQ4k_|HFjCN_zXru+tW_$Htu9rI3?dN)b@w{ADsod zQ+;MQb@5{2qgT7sTlS_|Se9v^>rwYF#VCF>)C1Lm(5(Zb9>^{{cM#OO{d zPvm5Go)t|$L!ku@dthM46F%lcPX3W_RJm{=Y)75wTtF90N&R^+g0Eo zR+_eDdv>g+6%I3JMP{Vzr7o`@(Gy_r1%pSYPPue@yuY=8vqy(hFTb4l%IN-d)GOoM1`nW!qH*gZdDmk#FR-FcQE9kZjr9EWdM`xL^i7T)|*mz`w|I@IH4 zJ8B914*niC|Na(xS0!iB+_ue=Gfxkt=1#{>@4IXFFtzXgI*QN#Kf)0{-WmxY0VIF~ zkN^@u0!RP}AOR$R1dzaIM1YO|6UqO?!52SB00|%gB!C2v01`j~NB{{S0VIF~kiZj1 z;7H;KH#+K!|9_K9{^k=`Kr9dvKmter2_OL^fCP{L5Zn=8T>!a2|UIOG4H;gUb|aPobI$nx54hwXgo5HvB!C2v01`j~NB{{S z0VIF~kN^@u0!ZLP4pTT|~CujWs^IY=hKPT`on@9i& zAOR$R1dsp{Kmter2_OL^fCQdw0;{l~Kk?FJzxjf;K}~AGq(GF|^e|I4L)GJ(P{EefdPyfQ$3*hzXeD1vR!f@*R`NT(0Tcp;Y?%N9G z4b8N4W4G(_#km!!QkD2><@E)L@ABl&jq&bBQ~CL&s&qwK;jgXCFIHA=@K>c9d}Xb= zJii2KEJ{n&DL*uuZjm8MReHP1FD=9WYYPj3_l9m-%-c(`H-wv!xCH19RXP?`Wnx8x z@Ry{^m9>Q`Kex8B0$Gx)^NZ4IwX%3ExclYh6>0v;lAZr^ZcZop73s1BG3KOIp1GO) zIZd79mzN;L1qlGnRaWOJm!z0v+!95SG0hEGB^HTE$%Zxg(NoV4r_P>D{P3bHtVV;> zWJA}RrlJSGzuYCJz$Ypr8+b@SL3UfDNu#U+KYhCe^4dpeZGZwQL|aym+ws?=TEBH44WA4Xjdl%OYzNd8P}nws8{ZO@(x z<)!P?gJNnZb@gn5(*W^~`9VXosLbYR?Q1z)7356#>eStZFAb+eG4aW3-4#e~(q;{m zHWb`*dLgblc>$ue%Cl3UiWO)1Sk3ixvXAB4r;gdN4ZYD-Q~mli;S^~i&^@9um8|VP zL@i%in!mm#@#oy4OxgAj$}}0=HKI~(xQOUO%zFgCwV z9ZLl#I{%B*nKOxR{g5rvU@^%I{X7v7V#vdh-koP&98SIZYT{nmPD3T;MolM%Dnrj> zOH-)be3>!Md?f9-Kwj4^)Xm!0eD&3ko}SU(9V6(zP^UTf1>=dmW5jr*bwi~_`T+fk z!|RPLv?)Gq?zR*#VrfT7`E;N~j??73r-oAcd~D4(s0v*ljWlFaQ)!JDtOnPqL0bwn z!3gZ>|I+QReR()FKcBb*?Jw(-?-_b;-|vZtfA6p%TM?0MNE1Ba@Vwnujp?xmw4z4f zGpcj<@+XH=Gc$>Ao$SU(6u@tWC^!zM;~+WSAuflhA3PtrQ7B^X_$5p`Ak12J);Vzd z6M@INL7=Pbx8prEhv%X9-PD}!RD*7H*DzlU6L8~D?||;KHE3tNVN!5ID96LGp%EaF z#+D6#0^di|3vhu)^2OW^nVUd7k(8K_<5F_k3by_~ko<&8{to=X4-!BENB{{S0VIF~ zkN^@u0!RP}AOR%ssS>CR40ER@PEN2#0oZbd|3p9oCR?2~72k=y^uNzizL;Q1r0mE2 zBT2CE_M%^(NMlf{f#g5olK%$&;0FmH0VIF~kN^@u0!RP}AOR$R1dsp{c>D>RJaTFx z(Qg7_;05p*?e<~w0?zaQ-{F$qdHjoqIYa_T00|%gB!C2v01`j~NB{{S0VME6CNP^g zdJ2a3HUg0gM)^IP0Akkv6Uo2i;ENw5fCP{L5|ns2_OL^fCP{L5;45p@<@qH@ zVNqJDPWhqr?H1gOP4CDMu`0b?<(HP>|FwmMAdqvbINxyOAdaCoC`;1KOW?6?kd{SN zxwadZV4E6E%``Q=B{%36ZG^*}|H@G6wUY@BCZgz$`9VXos7yMRZePpw2BnrMXPkd0 z?>zJ3aO%}p6ZgtCS(TU@HJuo$>`q9@Y$nT$apohm;{tiygOct~Jq*mJ&96w8r4?yu zPFm$z0P~!tPV&o3{3U5Y0-?`UR_7|0B#&c*)S_Sld3B5kvA`p(8=zTfPb9&uFG-gx zYYSCg@KRR{N|=m@6=uJ%0GV+@&aJILUaN9-eo3N^*2*?b>}J#EK>>rPvB zQ*xXp@64YWPMtiN_^9b(v4lW$lm2w z`;hg+J`mf7i{oKcMctI!2DPkR*{_sgZQi6t%e9MPXJRND&{8N?$8aig*pn5sZ(<#& z(@hGE1yonn^vq^Bs@CqQtZkxfl65LKNelkzt@~gR8jz9u)QvlD@x!TdIq}iFqZ=AD zvhJN^b3->QrDK`(zOHAZn$kB+NMS;i>Hyt{zTyo?#pzNI!WV#F?E=3xu{NT{De!v` z<9B&^MVh~|WOt9wxh0tFX@~j!>Uty@x1^C|JfjhllHFWP-dTQWI3;HYb)3$` zOVkPQ`ID%V??dz@A5(eWo;y99nx0PFbw?AL$wn#6kTId5Ig>NHXM25f9EQ8{QqlQ^4A`tLSa^r01`j~NB{{S0VIF~kN^@u0!RP}AOWAi z>-O6JndzRjfA7&VZ}jh7$BzGholE|@pDg}B0!RP}AOR$R1dsp{Kmter2_OL^fCL^d z0fAE6@kN^@u z0!RP}AOR$R1dsp{Kmter2|VruP7XZ9jV8o_KBN2>65ur|_VIC^|Nj*(`74im@i2c# z00|%gB!C2v01`j~NB{{S0VIF~zIX&KC!Xfmx<7ox?sflZ`%t1jgZ(~EV~{{1`PUqL z@q+}A01`j~NB{{S0VIF~kN^@u0!RP}Jb?rT2f0zU{?8qM0t<&lLIOwt2_OL^fCP{L z5f8Jc3s8=7h9 z#;%>IF1KuNw!1=vvdr_4-}$AgbVXVTXPK|8RhQ?NAgM)ZsTyRGcQ}M1c?IEDq|4HZ zv@|EJ@}{{Vv!b5U)JcAMiN7Q*NC0xKvN~6}B!yG6%qR{BdbzT;Q03>=Rv@veT%BK( zR;!i8YvEW*yCb)$q0p8Ex2=RA@_dsvWBpXk+>G^XQA;*<6YuK6+l;QI7iZp*^iB&NEoLk<>uof~m zliyDVBgwd2BFT8l9Fvl*0+V;Y^2%^3lSzDR_K4pm=@=PTxQ>euD}2ZQ5zD@-Vg}r` zmPL&%(vVH6=q=S3moHPl4vT^iL{khJHE*r~^h57a<9kfwA>u~l`>56AqwM5x>dcwM zx6cP^LKJ9&9Q|;9&h#pV<8j{_v43?B+lxSEhTfpe2DlAG;IVEH&;k{VWz>8JA;3I0 zHPh7emfWCQ)Tz9^Dl!2#o(N*mowjCB(`y3*j|b7727&h7As95TLDG(otmqwRO}wVi zvroQu3RAqkMGXVG4>Dv%g{DV?IuO7KTOMXIf$)scH_OUmIuc;3>i2JPx>@I172b#48Ctm}G1b;bfcrV~uW0z`dlL?8|4 zR#EDKZ#Z(``WduZpbRalSfTp$skB@XYaZcsU=tc(i6rKdd(dPab<^Oi|Njz~{H4!u zVgql51dsp{Kmter2_OL^fCP{L5}2zuW2A>{NOK{@#4A&!zz9{eS=j@Mk9|BIP3rafJ^>^&qMYxk4OLsAOR$R1dsp{Kmter2_OL^fCRpX1iqd) z%8gEN6Q?*4p5pfh_6<#;{)PR*0Ox6oKI{L^`2WYaMdi4ksMlnjWyd;C?{;`xRHP*Pp$N16(Yn4Hs?QuwbV@Z1qn} z^$zw+eVm-}|8H^0-+H9w#hW7mB!C2v01`j~NB{{S0VIF~kN^@u0{asvCPrbt;Pl1O zM6e5hE&O{E1nAY6W z0GLbq=6h3Vs;=8!@AcXLU+mrgUmSl*fjNL5;l@@*&yM`F;l-!^*1&h4+C26H$4+qn zbo@I<*TL)i;lKO*Oussms+>x2@FGIGV}8)kEGm`F{9z0Exw5uU<>%H`R^X;`b$(G= ztyUJV1*v;+Yq6=XEzMtFllXIfMyL27OOruLmzP(h`729yS%bin{EBp0g1gU2t1di~ zKc}ga{PGgyZ$SdGbCuP($|Wf#sUU}uv^pj=;!?8VPTosphf@<1iElP-x$Ess)7+3P zeUrA#$VE9K;fPmAtUP}>!SX!Iq8u++p5M?-iv{r1rC0bo&+@ELqf4{_E1qu~+7^Lo zv`J%S$n&&Gw1%x7;izV(*5d}1qC<@yXVOtM+EQqbhhk8;`~7Jj1!i7KzbszsgS@t#Tua z6A7*?4yD+?{f%Kn5#>zea`w(wGQ+82F>!C*R_>N=X?0B@7PDu{25Gfu!|dx=iYR;E zAP-;Z!$yFKz}(LQ@O+Cj9bM~V5_z7Tabs&@UtdLU)wT6_f7)UVL2teD4Q3`R_roiA zAn7|tgyGcmbmHEtj+m&mts9$QgxY#TU*BzYT^FLl>GFN}atRmH%hN-y;GH&9iTgGW zkvezS${UDbd1ff3z{%#E{ztty64FX+;OU3T(~&* zTtv=sp+fR@i|(j&s8cBNxDU8FvZ z$|jhOXoKQ6788ozY<61EL7xb2bC@Q^)Ye;CYu)d_1*zCQANyRlz=C%9Is>N-Vs6;k zbGp9E#l7sE%dTexl)ohvM)N)QyRkfy2n#~iEEoJh>j8dxS zidi*1mDaW(y;7kl6!WFJQZDCnO1YqryikB-%XKAJEGQ)iXOVRXSj!a%Q8GCd;)_Bp zLsgL|R8>XD3#}D15YQwI3qsZvMIbp+$O$>6R4#~=lqeAkG+WG-a)n%3EQ;kU$!4>8 zl_*(J6cn1v2vp2w3)xbwScH@es_2F~&vpr9(*3ps*yI24T@JqZK>|ns2_OL^fCP{L z5i!Mp&~7@L8>$O6v`!tUCgOPu|#t){8jT= zA}BQ&WWq4CCkaM zvO+;AD*0T#p3UbAJ%dlbKr~k@DT-Pab6JtpvI;5ZYb2jl1eIiAAX~`OEXik}q(Z(X zmNRfny&`6Xd?}No<*W$l*9tXJsTJ~hQYgY0zMfZzNNK)U6x13UBIj!vHD4%az$;TM zl=EsiTdWs>SPAl8t7q~sfG-F{6<}abYlU(xQ!W;ASxAtUX%@!TYA#30IS+15%@Rlr z1OPxvYKc-9&lhX;VzyMM3$*}lCZlAtH3f2$R|+68ngy!GGEwp{PoU&OaA_$Ew=I`S zVmVjK3Y26Dg-i{I0Nq-qR3bnq0~AXz=7)bX3{Md#u<3_7WV{Ein)^E4A7BcXy3c{@Yy#Qq2VekJ-B>yc3U;H2eB!C2v01`j~NB{{S0VIF~kN^@u z0#5>gXOB!IMkAXehS>T)zW@J85H6Mh2_OL^fCP{L5H0bKu&XM;XS00|%gB!C2v01`j~NB{{S0VIF~o)iMO{{N&%63c-E zkN^@u0!RP}AOR$R1dsp{Kmtf0o&c`@$Fo5nB!C2v01`j~NB{{S0VIF~kN^@u0#6Eo z@quT!G44mX;XfY!rJ>OyM+Po&KRR%7{6CKW+SuP1_}akN+;XlQyu7czK9pLXOmJFD zr90*a4b7r5=~%jbEt^y^s3n_v$55y#XQJ0{-u|K2hEu6j;@*1}sWqsfH>hd;45p@<@qIuxhO4Fr~J?@ z(xiMT$|72=)dPiE~XOPi`BGmwfwcGBJc;L68n-+`eDUc_st`NQ^1n!pBq^3oh z?dWa!14!rc@`^NnWy#jkb8hA*`4#E11oE7dR(a-T^5-;ll3!ke6c;2={<+HPT;)*RmIcs{VEI3TaJMZ%>DstD$gtJjtn-M88x_V zWg!qC&o^l^)(_f{%~;PCG=j#CT-&v%>6xBz!pz_5^7k9C$>~>zQk7G2#<|_-tZOZ3 zT-_h9-yNMDPMtcH_*ipf>-f9Am8b+AzmU*8s~VD5kWt?%`b-1AEwEr-+t|n3(x?$* zI48&!(Hg7+FcBo@bQ$7eD0;KmX+d)nieb=oP+VizYZyXlfobb%5PYjW0dixvRzoQP zO^Yd-35@0)J7#1HNmd+5mh@Y>AYsw2DP zG}c3N)FkBe_1o{1hEp>$iMvWjok9V6V!YNz#UT7g7w3jknM~r_XYF)MYIlObL(vUY zYpt8TE>|Nd_4-Hk)@#y3svFlQj59QWB*I;csHij;AjIxlODMyUqn^LNj3Lir$E; zieQgJRi~~S&Dj3yghh9(P+*I7gF zdmsIyn3N9ORZ!cd!#a)nw(60Jm*%v@VG2G zDx2@Bbm5TE3TJn}L$xg^cewWW7Af%dt56@mO&Eg2>6+f6ZDN|+U@)c?z15;hSeJr9Mvh{=X+(Q?PtU00|%gB!C2v01`j~NB{{S0VIF~ zIF60~2ag$C^2d@x@DF~F01`j~NB{{S0VIF~kN^@u0!RP}JR}0Qo*5hC&YtB)ZoOo$ zUhD8~9RjZxGB4$L|M$CT_7(*5!j?w2(^F}9z+Q#d?`WjqJDDx5-KG%a74T6UGFrA zVZW8j%No0HY$ljzIMa3}HOT7$GqetS_fymJqbX6e7xqK>v9Bv^wV%D*hLPj9HF!x! zz0+v$&TAvyO&|D1?EU|P$v@C22-QKU3B>VlfjK6mshY2gLLL6lVj)9RwN_A-OtxGv3E3hgq@1Ua zd$X)FMf~!5#u^ z$d0_*e(t3aZoGR>fW710dHlckD7-lL@A>e^qH1!6Zf%_S1`vP--e|`5`ClklHQ7>yBvaCInoE`0(vxW|Niloo3+5ln9L$j#NUiD~S%T6I>_M%AHr0}*y%ak*H9B;r5hu>y}xcmOnaH>*C zd?H(<)}Y~_km`o4Z^4TnH5F!fYBNV^oF;7iL@erauC!SQ7;agf2cD!mQP z5wEtPTe_k*qJI1(>2hUlp~_D{VJ4hJg7;Cfq`Z7ZQ-d(XqN+@+nDpk>R#qU>a&>-D zTCG+Vuelj)!*ewE5%2QyiZp*^$)wUmQx&*Y?rfAgLY=?)CbH z_2zKu^y$Q{R~_}Gmc`~DP50l`sA{^dt|BsJ+@!mXP(!Nd@MFGPq|u>Kq1%c%IdgR= zHFr9;Antvj=DEMBcmA_C?0kN_?B>&+F@=0O|6cFPr{fyRr>EzAv$;o2V5N9}+mbDP zleVHI35u!GEwF~NwcGA0Y)=raRoCNE0pZ-2y0a|OowjCBv!{v$8EC+~tV|S3+X8cU z@QTQ%bxl=dvr|jk`JVC|G}U)7OV0TFbOZ3slk#7K+bWp*=O#BE~wkBKZuWH?-DfxGtzFQs!Ba%`?b0SU0{%ufCVo~`XI5nphzItUSwLBSbw;jW+$M&lDO#JfU>z!4eIq^#@zp#ME?VWiK250Q z=-1a`ob7UYbYf-Vr z#lLT^Y=h*@~neRkujWd)2-DbIXQC zU1O#ot8tdWsnX8+|F^m1w;$awAMcC=kN^@u0!RP}AOR$R1dsp{KmthMi$uUk96voe z@!ZSYi3#q6c#-2qdY1Cpv45}z8Eoih695lfF{Mgm9x2_OL^ zfCP{L5p(eFI{mrs9n z?CsHyM}8F|;0FmH0VIF~kN^_+ToAZ*^@SrBZ*Q_!2FWVjl($6Ldv#DlCG8K>Tj^PG zDoq;e>De@)=5#h+m|j8Z2^Ug>wvw{vgKEziASmRno%twnHGEO1}Evh;pq!7RP? zgZ1`xF<%$x>#a8~UwQk=%yyYp7ME8u`jyh_?S=L8^A{nGMOgapEWF|A|+{UQqh?=EJiYgAa;apxALZ{B({b>!9lrGZl}_AQKl?Spk~?n+B(mbb0T zn&=g#xs>^!P8-5n=lqRI=gs9e-!e1j#of~Cj=Hi?y{^s8ynSQq^_go{RR{{RyHm^W zEUN2zapTg$sxha$F=y5eRhXsckIeNi40~-iyoGz;0-fKwRIaYgX&>xts-{?Pc?DX! zdgbkU;riR#dR5y{g<8hkT__9N>O0FbZ^8e{>h|h2W1Hk#yNf}Ac8YIx)@xR$Q`)rj z=5^Y<_D*SY`!LeEdLmBOEZy2)*Yf9$T)DP;`Rdj87qn}OrdOIy^V0nKR`K;W->l4B zy0NmnNwwnqmFugQ>syOggtg|i6?2QW-hZcgX)P$te74@%tzQxAGaKthac6nWlD<(o zRB0BzbmX=E;vwvGt$l?vR}|TGr@W%U{><{j)EVa$W z&1=S5+1h^V{gQRHu~x4t!dhl)er`7x6zA=Q+NEpjT1{VD>}gDs2oDqCY~K!JmH;&;2+Em zN3>hCWwFEBRkCY}g}iX?nZd=B7myuN0Ou#k>kYj|8jW3CI2+}KP7)L`ClgQB)5`kvXXr9*nc?oza9InW50at zCysr5?EPbvV=s>Xf8+oA_&*%~>G6*t0)CJH5Ie7U;RJ3@#@2 zo3}W2Zg8zPP@k!gB+I6sTGx!q@h=U|jP{ICzdTq@L>;~H?m%+zOd>SF@pNiX^shs+ zCF5cD&;-d*Ry1$kCT=3=nQ)-EX{Be=gYyY*h~IxYVCb2_i9}@LVfg6a>4Y}{aOC*l zS?9tX>kl$zh?zWi>gB;Ji9KgTMp=>jOo5D?7@SG;O!kc7^Zzl`6`hd)5ToKbPkg*+v12hzb3=x6c~o|y2Aqh|G2ujves14Cw<1-l5?`&nz98}a zLh$Ftc=w~J{QOc?x+1Oc*H-2iD=RnntI`d=vQ}N5UxI`drKReWA3AjVP{gI>D!;V0 zun>f^U$wYr2>Ugh5TYu*4Uu~i0B=<$R@9rnBwenoEmZlrwUrgfmt37+lvb;i#cRQx zFE6i1^H-MaLY#|acamR`E=v$^PFn4vV)ExSb&_9Rf;%k0@i6>cWp%D{Ns39oH~6v@d><|lj%iEYqzOodjGx`mA~tI-!k7vQZ3S? zQ85M76dexSXmv?3@K&j*7+Ra14`Zhg_<-Qxu#x-IfKD6aaJaa9x~?}=YNVZP@M+z! zue_S!>8G!N!W5sjwVKu)btvTu-AhrN<_2)oQU1_%Vs3GbNo+!aj3R06 z+c4ScD_t8(-4GL;)>7$?`9VX2L!(H?f-|GwT4pbL2I`XhAE2P|gHP|A?I<=sS2FEMYz|`#_Mq=uQwr(G!7Wjt>5mp5_Jk8i`vpUMM?_28rK|rz_ zL3a9wVJ}JFh8ES%asME25Z2zl^1%|>FDuSDYCX4lFyn9^t%%>|^ZeS<{Pi{ITvWNH z_^1X=`I<5r1{kUS(G(&zH{^9#>(pOwBB@757>Bkt(UL||KA6S1`~K2!s!~aOBKMdI zTRrtHY8V=HHp3Pp>ho5t;fRKF?MKkG?Kfz$$%leRThTr2!@(^>-wNGjmF`!;?^m^Z z6_bsxy2fAo4woXSxU3?n?4=)0J&7a{Ar(pJU@8o?cJlh-P>Q~`PxBVKQFkNP>-7yb z{!b+TAqQXlAOR$R1dsp{Kmter2_OL^fCP{L5fLV18Q!X%}Y$% z$2@-KVUXc~Dg=wKYoz3%^+-$B0UuXRP<6QE`AKwCE&X52SKmter2_OL^fCP{L z5SX|I)x-lF7bH~4+!o^}E0VIF~ zkN^@u0!RP}AOR$R1dsp{_<|6y_x~ULM;v_dg9MNO5sRB5}R@4_3{rm33E=QHI#OQ?OE zK1{Rs$E9b}xtf|63$$LYXA3of=IhyFjh4!k6zVylpparMFBHWrRWtcqy-=k2a;cUl zNP{)UuitAw;o2>LpPrDVbchoUIiz8BrB-f~{qONw=&P^TIApJZwf&?F6u zArMM5L+eFVtO>P3hS0L2)Rl5ApAl$QEs8~|=2THt3%PnRmlvSC1%>6fNW?M~OCqFX zPz7GmIj=%$ner6v#vM$3S>1aiq{Nx4K65Q!=j z%DK7%S*(?EC7LZk(sf#(@KuXctk*$=2UMp$DzE_#e~(HvT^{^!wbAj#~dO7B!C2v021g+0MDUtPqgt5E7;@6n#c4UijQ(@_E{p|e#v>QcI5OS zIEf;B7?CMQU!WcJ483(f^eXHFoFuZx`?1+s1OB_|yWV!q{Nm4+aUR(0dIvo8xXmu7 z=&SAf+=AuK&qDB8_Lw)-_rBsDT;g79&%FGX{0B+fM?T4l-hnf9_Bflyf4h8xnD!wA z-LKwk5_{8>9(P5!5w8Nmw#4HyHjguO2kQ}7FF2E177+4zzQ`ixlfF? z*4po|9UWpu&+~!9dtwf8iM%e{)xOA!!kJs#8}4mHlXz&CU61_wau5p`!5$2Z;DCw< z{4Sfb?@C_msNQ}4()mhx=@MaU$KtVA0_{4 z=*OS>+eiL~!GALFza;)o?$7)7u0H>cx6H8(ZsN>Ki4!lK9N|v00*#Jxqa!0-MZ0(r zoNY&V7K0lZ9eIw;C&CBj7~u*&?2`RR3=AZghnIr=?UkV!S4`X#s2Y$|+j89&5Q zn-*;=#1(R}Uy+>baETg{*RQb5Kdwx1?t7Ir zcdIi9Wu0)#`tm+yg{I}<*{h(->uR&uuf##9xbnrh?^SN`BYj|#n>gu~`#J93l{;t7 z?Ab1XNxL;Znz^6mxTj%)k)B)TY`-#tx4#yZ&1Hy4V4bB@uayd7F(bl44=l3eRj~{U zd6`nZ3~PsqkX6h1Vm4bQuxv%k5Ln3O)C{ce!J?g-DHm(Sa!Db1wnkXX!17*JfMp*w zuVhL^v78t4YO$E7upUQ?uv}KG6-Y5(C}j&JS})aLArBUTR9Jy32*naagw?FFS}qk} zZB4D!3;7x;d#h1d#m+-LOKXAxOPjEal+DvTtWuT=B&#U?Y7{9!?gb$OYi$rLqd>B- zpd{2`4H6bF%f(PR&yZp{V=qNIYnDoel5DvSYh?wID1s2ETSOP9EdFh+9WiOJlPUF|xr1>&* z7h$=yrNg4(Ec6|x?O!+ds&Uy*jwYJW`*myF={rTclW=QhWOVctGds7}^0!3x13nio zLLZkI8{689j*Ry7fbTpvGSjaM{IcBzhCe-Z&YPifD_*8w#q+}WwGWnB)vQvifg0pW znT(>s7gQFMnbvBQ`lblF@d8oORX^Tn~npaBoJZOMetd$Ewrd+0VqQKfaAtX~O z2|1{cnYtj9au7x>6vTR-RmhOm!}9jKAEup?G_S$rP#T0=5~k9$rMBUeQpIhcnL&LOjx7YoI%$~n=6f@yu{ERSAX$r89dmAjDw|S^9nK94>UJ0?OyD&S zN8lP;1oW*8H*QkP&=j*fJWMlOY3Mc5Sake@NN$2|yudw|XL+{o`j)1`0iGwca4VXTJhu5J*ZC!1W7Sms^@GQoMX*ipDHVvo1)=7&%+y<#pRyRPq z1IM*Du2ENdm1tsu-Yse_7Z=#B!Gda zi5ajI8HIu}WOL;lDGDXAD1z07aE^Kvg`7xorDCxJyB$gt^fZ$%QLwbN3}q$~0($BN zhJ-3vT!l64IZD+e3^_t5TI9B5x=+u>N-1Q z8Y0(pVyH5-iPRu~aLYM#IuHtL(o9CUuB~_F(xKDe&*9MD&r^#2zAfE?8$%yPX`k)dnyXwUoNo+DE;sabIOuzQ zk5#b@tbe(Y6uk+W59hIKrp3Ak9>IQ9kx#$hX|`>aroS#{Ds8zz*NL)MTmfzv3|?VX zC%8}7k-$MH^Bi0JSzZELupm_>epRaSOKS@Ykd$|xvmAu>k1F?5hH&^{l4rSPksrR$9v*(+8^g@`7D83O$wEo-0q1g6+t+;(!@%PI{meHwTk{(hWEMgdWR^ase_$&Y*mn^Z8w;X?Ci?Q24Snowsqmt_Ie@M zK)DXJi5jqi&C1v9$rkB*T}fjd&P}&AnvR#>o=rH50~1iVQ1S*W(wmg_xwXB152u%F zbqxj*JvWC8L)KI%6!ho=1ec`Cm9>Q`&vee*(6{}D7A(1J!a?Z!%hrw-ZWs?1HMpbG z_8#bdek8ZaI9w#?QUM^R)*LvKW=GY#8b98C`l(-TQ~gicadRg>@0yH;Z&=?E5=LAQZ+k10ko7 zA@H$;DuJEPXv)kfbZPV7$D34XdAEiQ@|ZwJ6lUfc=GT zkXJp40ZS)#*9CeXeiB_mW5dY!UdN}mW5^!=Cx4EEFMf~!5gYwVLT>8altkp_N}`(^NYTt9b|9~e%}%p^YQ*vs_xdV@Dv!yc#WacD*s z?t8pLPYLpTXqM&x&k4d4lrWLVrgqr;yuYFlEZOnAJ>MQm)tmnchw*a1*BU*~L)qeQ z19NBhz54fnj~Rta5_w+9E&5@eeDe2FOn!7;9!}-+i67o|q|DarcYrj^r@>kbTO@`} z<-Kl=sI2>hcp!26_uogN`>I9oNMXE8*&>ktFlJxn^4ELf6S4PD+1_C}vGrFV+2ii) zPK&1IPt8xjl5K}HCL#$L)D{V8geEw{0i2bCVD8h?e?QgG&Iz7>h-w@XRYU`$TBE_T ze%IXVTxE5xa!CpoBBIu=Y$J&>w?iisneUDy$5uxI-=2iMHR+(X+}(a}IF-pHzIn25 zZBf8BMC*z|-ruvPDCASCC;Zyd{Pnd6--l9NLeKg}6*w$O*!-xe%z&t5*Yqdkdz2Ek zNBVFdR4eb}5yQ~EBdYh<=(L}~$+_97_i#ycX%M8k0ew|_3+wHr8}_>+?yfppIN{K1 z+0vUe*k#m$RrBa&J)%Tio)2XH`r0!-wCrcM7yGw6Zf9rjPJp{W7;p1b+JN^Ps4{Wd z`#@Q}wd$VdqXY5YNxwaunwUs@H0S^|VgG`mZ-F5);8h%<3mSzP_6ng4b(;>*z22)o zycd(h^f^?`Y&XcR^QMOQ1|ifQgti^}D1+AGL3%KUZz*g+P2SDDHJmzgCh^IPE2ItC z8R6M)OL2cHT>|pM+_!l8YI#6nFX<_ILw7e|_`0S6*scy~&fAE6@kN^@u0!RP}AOR$R1dsp{ zKmthMF(q*8?7$dz>cr^iEh37rwB1lioBjV`x?{gmgZ*Z&)L{RH-l+kP&DUUd`cezt z8lbc;I`ffJs&WzDsNr~Bb06ApUOm79vxoL6F_ewhiD|!Y!w&7fY+%1t_gg0tW8BFT zqa(NG?0mw!nXah{yzam>JJg82|3g!wSydVu%mU7{7b7@1g{(Mks}$w|-1kE`F4ORg z3UfMOzS#JmJN}po7qf)~kN^@u0{?$|_a5cOb>9bk04x^^J~X0f8CGOQ(9-lSLM}NE z41l4-D68d)qQr;zvL3Dv&S2)w?wD8r!VFfsk{Np}$#Uu@=_^T-G)dE>jngzun=~)G zNt5;@X>BJ?)6{u7e?&d8YoADIoH(bo&S`&l2ACPVmZC&S=JJcPTrTe1x%YQJbLV32 z3X9?KXja@PGgWAOHafKmY;|fB*y_0D)UZ;F%VE%Uo{GSD!nX8hT$j zeI3yPvlLB}^}-@OmejLP8B22G@e`-yvzL}No_6Y_ReJoyg)3)Io|mrmf59p}J03YSSvFR;ku?Lo-yPtZS-dR%OLBOIp2ZmJIre zJF{w5s;N_ux!&- z>XxC)q@dB@pWjzCEQJDN z)EHLLtEx#Rx@t?+vZ6|*a>XhenkgB!rq~8kB)M!TWdgAbiQ=SJPYIM~ zuRcEJ&HiJO!t4Jp3*yVSOeaS}ApijgKmY;|fB*y_009U<00Os^KqiyRg`fX-Sr9MZ zQcXu=ApijgKmY;|fB*y_009U<00OUFz@Jau7QBt(z+H!gJB1woV#4^A``6)}6p12x z@6n(d9gDs+(D61GuCUEDquDhYW3K;)^Z!o?;-_AFvrz&BAOHafKmY;|fB*y_009U< z00OVKK#6}HK<=(P2TwN8c>r&%yTSi6RwmB>KOu;pc)jC8`4E5r1Rwwb2tWV=5P$## zAOHafT))7H)C|q{Qws+MC;aK7ZfE_dRXXZ*jHYX|4xjq>{v0>|pE)V)6l`JU-1Pj+ z%+#Ok_;Pw>>W6oIb=S`c_O1`@`tYs~Q)JJ4+xxTmd+td+f6_DR4aVQfQ0ufB%#Hur zSh}#hcxkzCY4N_(%Y`_-Fu$`qk01tG<5PsfXS{Ix`hqd8Vc&0|)rz8RDe zui9T=c!Px(PAuQQc=`0D!qVjn7buq6rBi2?FJ4+a^KiWG<+G>GUtXT?1$Vg63!@n4 zxc}URpYO`jIo`$_BdghJxq-a^#n}W^T z-?E9G5I6oB+ny?|d7EuExcTDMS6i-^=o|5ZhEZpYNo(;^=>s@eC+emBJ{5HgDK4sR zm{(ZSs=3T{omP`q$9rm=!R#=DcfVKD%erm)WR+LB?(5P2dY}WnhTgMOl?7*J}mOYr$rYI!8?&eBS-=ZY$_?&s+>Uol|Rf=tEjM ztZCBFHSnx6*y#qAB|6vXxxzZ_j?)|(jN=`tmvgHfzL&*m+O0`@ z_iNc5*+$#xFn45Ej$>>XG~!V2dUd<|YuaVQ&T-tF&YwRtX-6B{gf)36;fb9;F3j*l zm}f5=K{xp*?bvjr&_D2~(PtCUn-PbeHix=Nc)ZffJJ?Nb-0QyE>UK<48ye!r93WWK ztzT)jw&AT#>Nr6;JVR~ESC6O%43-nUbZl`<@tQV&+y@7W8DzSifqO!MNzUsjF6^H4 zCDpg(j~-2Zq#O1tx}+7oa?*t@mtW%=NLU-}Tf>VJz3Y09D+}S#ZGY_QzsASSE)=fa zxeA5e>%MwrI)Cz>N&8*!dJ`O|^JGm%LTB79Oz0(srxq z)m-PP;dH~~qfO0hHCmmaqpF6{T$N&mRBf$gvDvVm!mQa^qc>aA zI8H|?^mm&EL0Z@wzYfRC+XgM6qZ!uZ1ErPB;;y!y3`ZW`wUbMNjzy#BW{vF3hGSky zE^Rx_n$d38I)>9orqjz$5K(UtUw5^&&N|6FL7mA0YW1$?wVKI1cB?^0w=Q>W7;Sp7 zN-i{8P21_L)!e4jZZogOHkjG<=*Tvi$#PuY+FHn6GgsMQ3;KmnucYR++yS{&$D((% z2dxXH4H3EF)C@X^c0HP}jq{}CbS!OAyBnJne6W26SowA`%-(3f?yx75F;lT#H)wRL zQ*$ZgwZz7zqoCGWE$Ew6VsHRYn>b9Rx82w2IF$}i;zb_otT|1iK?hE@(QSC~l8)6L zO^mXy8D^)|&m0Vid=fM;V)ypFfw7gRO`0l(11o?3zi>PMg$D#6009U<00Izz00bZa z0SG_<0^3!9fBrw_|J!xzqFoSx00bZa0SG_<0uX=z1Rwx`PypBe(Gws50SG_<0uX=z z1Rwwb2tWV=+g||J|F{1(MhhVT0SG_<0uX=z1Rwwb2tWV=xc-km009U<00Izz00bZa z0SG_<0ub2#0=WLa{kJh%2muH{00Izz00bZa0SG_<0ubQW|5M`A0{!3t0SG_<0uX=z z1Rwwb2tWV=5P-mq7x2@myXR)^pn}zb!gIOcslQ9GN(=lO{1yz0zBJG>I#(1)DKAL+ zf}$1V(y>y7ex9apoTRV0Ji*%D>g+MOe0Y|=X_&qe^9=J=Th{C`dP?7_*kq;Ku?eqRv(K>TU(dGYUvnz)Ca;sF5&KmY;|fB*y_009U<00I!W83an%X<;rkr$%$# zhGVjw(KCKMeayA=cTGLyd=`r~Z?wA%1B=Wg#Yh1SC)U?zccmQg81n@ ztv%9Cb>~d>wy9s6dOrR3)NiMr6}~1kZ)7C?&L^|^PT&8hp!iRe{mlp3ky<8ZVr7*v<(0e#m;nMP>mkJMGICW<6!efPpmLEGD zE$m5!InJKDR5*M2^yzqU(^zAZmc>#;J(#I*`Ru9lmzU#m+TA+cLsO&sat1TTIm}kK z>9G#=zDvs|moF6hu@p`$-@kbI^reCn=e5{6-H=oBHrruv@she>UZH;1ZNH*&ypGXy z+pUi022sVu1S+(XIJRR3`^jL`QA~5~4zpQ@y3Aa>a@_eRthIFc!iD9tmuiNd$OI)7mKNn6%(DQzxH5R9~{%4 z`Z-_U9<|lTS14G_H9Jn5zl4r#A-mPEXdvTbZW7WnR)&X}LLqFeVOe4Rv3()T!{Z4q zMT`{OaxAm9#)r@Gi#n~w@Gw~@@I7JtI($jA%7lO#j#Z;QrD-~CdYv4pmWKIpgEQ5- zYS36YvWrIJX=03jt$SJg${&OmH6*HQbQCVk@}o8yLuN->^qO|3=*6~JY)$8n=Tj4o zL4pCiCI|n0+jCDfv-x~J^|6b=P7)*rzpf7K5O}iaV)0P!r$+XtMBc=nb%TytlCPV=&O82qE$&r? z!dAO14U_zX>4!Ec#Yz*Rk;E%B*9j6wH}G6|g*(v$sSLMfF`FM1_BYJvdIKYN+t_Th zjEPk|eRZJt-e4OBG(2H=6Ndk&M;8jw;aC44Em~GouT1Ap-m_)b;ym zJ3lLk@4xN$c9(X2|E>x>!2<#ifB*y_009U<00K9MfIWT3!Mpa%ovH zva#{d^+fjE{>FoEh}RW|E}Xif-~> zx?Q(Qx}__!rj*OFElIX%DonSedbw&=D-x?$6+==>b%nCYx^3I6MBj(ztW!B9qpH}p zLQ70VwwYcxBtx#KtX{TDCCSt&ADtoP8BJL$8+F|%Tc*UQQe7^qDq}{ORn4-(3v`W! zM>#cHs>&rr(iKV5>g5VOvnUv|Ql_Y6R#$CPs>qd!E~|RoR5ewVWu;ouR9jXmcAZg4 z{A=4f)+zoCZUo8ag3s2zrBY9*syHcE=3=`yQSwW_X~ zcBxt}S1e63ZAq0h#i|(9a#fZzxvW>Mz64XtvRu&(sbZ+IPE9N+glAV}O(iwej3i`B zDchQCC=@`Q5vZb7X&tM=%Cbckv4pMEWuwA$l}UD~ZrU=fYRXmHRxQ~y>#}MoTAg6a zcEzS>Wve1HOQJY*!_@Q=FKMF0q`lMMmwwya{(=4(3a1BOfy`Y zg~!+fPpb0b#l>^053D}0@kEtfPgMN<|I_04()a&~pBFzP{-XGCdV&W8AOHafKmY;| zfB*y_009U<00M7>!0nlX(Md~w5-lTU4nzrbUUA31%%SLc@AThPawUFxb9!%PF3J>~ z>qw9N{=by?j6gqlKmY;|fB*y_009U<00Izz00bcL`UyOp+9%BIr}B@}zkPW@m{HZa z{(C<9Zwd%*wOjSPwtMX8(csAut8|2I7;9~MOF!McHsSmI#(d--6&-vAKu`*Q|9@)F zcM9}_2LvDh0SG_<0uX=z1Rwwb2)tne&$d##=xf`bIrQ8~^?3Ytbw0O?-}cWGRbyp( zLp@WgifQZRx@4-Brb_*{@$0Ovm&0nnZdJn%+ud|Y@Ns3jE-o|e!3f&M< zRt&jnsW!hAp{hu>T4L3D#nMeRejC4}8oFlJm2%yrTO@2&x9ob!rYxpu(_Io;RWj;k zxnk<|lEKu9WmHS5TGlIaoo)+|Bz}iM;%)qL;x2}IT~;)vRZFI&NY#>MDZ0vUFEAC2 zZbGP*bV;?U6^(8lP)(^+E!Ab2ZsDj`>PFe7=dxx=x~7}es%7eCMYF19y3NC4Wm=|} zWkxqvP=tz1Qo3T5X$6TXlFZ6w)2uSpsM1X$ifov=QDTZkcRbMj5mke3!H`OH7f79W zN+nAwD-o_)r&iY!@%LN`K`2%K)epig{L%Z#Z?MYgM2Nv>4wibS_qSTZYB zl#-%ZvcgLm#2qu~r+ZO!x{Zn7$rRki)W4G{{QUnH1@XlmlIN zTkq!%-6P8>Vdmi7Du2UA?_T}|$Lik!&`XJzJ$~Z6_n7)%fy|9rv5Zlp;Z$P|1STn+5AI# z+CT0zEwTGwTr@r#5XTe`5kcxkzC>g}r$s!^tO$7HpJ)4bx=+|8!RLm9tV_-DO{E}uPh{&KR> z6ISS#7sNNd%=uoGMSq7cX7eYHrv1C4FwG8qsbtf0j7H5cP3DH1f&v^_ApGzM)<~YD z*2uy_?6vbr+GGdPekF>-aB6E*J9P`UX11E^tm7J<(`qKtg^!E`kXSmf7S~8Dn@^TV zZ980`;0%xV53lG?uV?f3QvW%W3?{ioXhP);Bnr=s3uhopz|o6kU_oEvaG~3_`hh&q zozA~kOAA35?h_5iV>P4ewSwohCt01ItTt=0t6NjXr3%l|hQ96E8@kh?U2)uo{-KeE z#dY?3NpEG|xRHg0`QFl^pZE9BjzK*x-qIu46D=AW&JVtVa1w=v`)VQ~?AL|)L=xX; zbN+5JI+Wb*W^0XxCabpUxL&KX8TRJYmg@~I5dP_CNRw6#ga#MJTc4LrZRWx3qCMhZ z+E=4aXto*+qh9N@T5E1?rOPO%*Sf-*$#mfddV!1#7s*`7wZm}Yk_+=;(R^?U%P+S4 zo!Pubd*|LL7Tyilg8i|#FT~GLl@fvaSS<|RD7&Tw=bJ_fv<7wX*Q)SKT zI4i-+g-5$e?F!qBD;QiReBUIFNo(-Z2E!XHaXzskA2-~72t|LlKSP^LOZ&xfF}vm} zv$_o$&8#j<6cVKi|6~idT9l2iJ``ZIY`)1hJf0yK#t!&d+SMqYQ(MOq2NVAl*C%}M zDHJVM8GeOQ?0piwMO|8_5Opc>Wx+ysyRw8`YB(?<`=2P#L-IB zs#=G+t!{^oH3!=I_qVDuvC2@b@siCpJ6wo^SpQ(y==gv;ZXg{W@WZyj0KRLh0HPI! zst?nX_13;^I0SG_<0uX=z1Rwwb2tWV=H>1GY zr{?Y&{49VrYg%-ME1dtoAc!yAjLkyK5P$##AOHafKmY;|fB*y_009W}1l}<8Y#f8lmmK>z{}fB*y_009U<00Izz00bbgodq!e-_BbXZG!*=AOHafKmY;| zfB*y_009Vu0+|1!CqMuK5P$##AOHafKmY;|fB*!xzX0a{+kYFQg%E%M1Rwwb2tWV= z5P$##AOHc(|Ir5^009U<00Izz00bZa0SG_<0^45z^Z)I?jnP5~KmY;|fB*y_009U< z00Izz0OtSb0}y}!1Rwwb2tWV=5P$##AOL~wFM#>~_TR>6Ap{@*0SG_<0uX=z1Rwwb z2ta_(|5M`E1p2`P0uX=z1Rwwb2tWV=5P$##AOL~wConaY%jGct-+mhwErI|9AOHaf zKmY;|fB*y_009Uj1Ze(0)0_XlAc!v{mf%kaKmY;|fB*y_009U<00Izz00eF}fp<*h zj^`9fDKALcf+Sy(rDGERIU-5YN7BJD#&j3tg9W77T06X>L9Le_0S;zSYgpeh5GS0uX=z1Rwwb2tWV=5P-n;6v$-y z_y1%5zdg4wS_J_JKmY;|fB*y_009U<00IyYgiJ>0-T(g;LHr6tAOHafKmY;|fB*y_ z009U<00I!$MgqI1a{Kws0j_6wEWH2!ML~RV8?_KkfdB*`009U<00Izz00bZa0SMgE z0(+{r|Ugw?N||009U<00Izz00bZa0SG`~TMNYR|Ht+JZM~t}22OXf$@|2KU) z^<&~gyX>96vU5l7soQ@2wgY=Up1OPIJ99rw)%d!9G@HMCZ`ya9ro}cKYoq3_8XacU zO!~!|o?|p>E~6)&Tl0+03jIH6x$vt?7nT<%?k56R{yVX zf$+a+YYjrkjK_bbH zStk7Zk!Z$c>ld-J5Ix9tT+YILkjgzhw{T+l;!@H7CVw`YKc%L9b1=?TW;DFjD9lEy zb*0-5Rv%m<{OtH32XocJ^`j8`0S@LW%tzZh3e;R>=9Qoecy7@@=-)%CwA~*W8*Px; z4SNkQAsKJDO!%2zxR=kKI)6FYVMp`E8_IC`%X_zK;nSQqp zstl8ae>a|Sa0TAy5-at&sJgqis;<*&Fd|H>Dg4_ltLv>WTwO1js{6*Rstf8FtnR0` ztgg4haCN=pV%OiF%`Xz>-m#c$qwRE9MuMc1)%UKQ*T6WQP9c535cti8km5{ky`d?@ly1nX~57r@{u?G?^;=>t5Ak zdUrBkf9)|5^vW&zd;D*pfnhG~Yf%Vxt6|Z2-0PFAC!6#nOdD7t{NxD0!17vhO&-F) z(!zYWI2sDVrNyRyXEv`>JlQy&24j3gU8}PWji|K4+sPE+Uya6*Tpp|u)Spc4kC8=x zm;d%`etA$JnX?wwHd^mbOxg+q;U!id4I<3RN9z7w@PwbIosOc}HKF~%Dg!kLAHN1b zkb83F#dZH}+5GWCXSVoxaoeF6?KPuGuQnYzGH-P}vrAhw*x$x35`JtX zhAqkn_P(*@#J#OQ^!TE`k48c|NR*?#Hc)i0zmCKfrVBs1bzq6LN1_UsjScB7TZYtj znoXL^jD!@X3;%rUkP>T;gcL3t4T&aIdnu%aw0~!)wBa8bK7Q!W5PGwT9`InQ@E=BE z8_W=PYscb|4KC>qUt3R*nAz%BPIINVGt+3%@mMqyBJtj0;gb^si8F=6Rc-5e(qshW2UP)wJcS1x_&zAjn(qMFaWct&erVS0E z!FtJM{W0|CdI60&@JdYjhDu8gp_57;GlGVn|M!w0zBJ}^0g@m90SG_<0uX=z1Rwwb z2tWV=5V#oyZqE!||L61nwD`Az_%i*%0|F3$00bZa0SG_<0uX=z1Rwx`?IbXj$)(bn zOfDDB|NmMLzb5|mc4{En0s#m>00Izz00bZa0SG_<0uZ>>1*WHRsZ=^WHI?hn|G&zw z|9|yXcR~9h009U<00Izz00bZa0SG_<0^3VqM~0sO=wJUAZr@(5Lu()a0SG_<0uX=z z1Rwwb2tWV=5Ev8)KmYFwg7}5ObUcFq1Rwwb2tWV=5P$##AOHafK;WhmczdS8zxR)S zA=AQYEByMV1^&HL`27EyZd)Q?2tWV=5P$##AOHafKmY;|fWWl`#(w^PN_?5_|G(C% zSPlUQKmY;|fB*y_009U<00Izzz|AO-$q2#c|6~4tGj2V^3;_s000Izz00bZa0SG_< z0uXp*0{zeb!~Fl1c_9}BAOHafKmY;|fB*y_009U<;ARwvum8U!h%eoYEkMi=fB*y_ z009U<00Izz00bZa0SJr{7`Xqx;g~G^{J$3k@x?K@kpuwABqgc-~TV% z4jBkQ00Izz00bZa0SG_<0uX=z1h%t4`2PPt6U0B;&aFh-AOHafKmY;|fB*y_009U< z00P@xVEBKP`S$5MQQWct8LG5P$##AOHaf zKmY;|fB*y_u#E(EWKyX;=}ab<3+MkoDTtpFe{vf&5KVyq1Rwwb2tWV=5P$##AOHaf z+^hoMG&OfTWtA4XF6%59)|%6tbW?x2NLdHp=sziI`1yY?)Aj$Gl?8D_00Izz00bZa z0SG_<0uX=z1R$`LK>zxGO8hH?L&7%-KOyXT z>&~Ca{Y~zvnZKEND*Kh}w`b?3pWpF+c66q`GWG1#TQVQd+@JoPw2}HuYDxGB@p*CP zw(q!YcF!;DsqX&yU7vkJEd96n_hs|vw6w1}O^awjxD|InedUx@;7H=Ab7YpvB?uhr~@ zQ46_-774#QVl$K@XhUyh9?8(+ejJAjz0Kimwpp>^-?5y#}N*6dcpVx5qM zVt3;t;lGW>5ibp6aIB#IIJsYU(e=M2tZv`f>Vl95tNWcvn;;QWt+(EAt-a)8%|AY! z*Y>A32P(A;&*;a?3**m)&yHfN z_1n_&-<{1Lr<%mkn%YK(wry>-<$6(rers}r64?hDl}P9Pw&5S6ZALZj8m-anuwd^t z8c~IxnOtF3XfL^Apt?BIK(9_B7{$EwP6z(hr4TH(R_b1`#b$gHh+@F(@9}aV~iCRG>O~Cg@g-)|9TCL z#QLKi;ldc@9ez2Rzev%%djd+Yv^a{fG5wcU7E@fx#K__-ywB8pZFmQ7I_);|;=a;* zF8tYKusm2}KUw(o zYgF9NIM4wHi;gK=~25WXHI4q5u^v5mIVq2nmS z>wSTiEBwmjTH@RT6~)O#I(9xc(iOwXY5}=h>s7>i!oULImq#o1P5;4czPp(I@Lj={ z?QJ#wf?YGZ-fFG2&N>}BdUb;r@zA*4X?ZQP)u?rw&J$fWc^%mwIMgx$p;DRn^NrR8=pP zhQ#IJAu%Yp)n-i^I_iThi&BN(A75pZVMH`q61DOJ{)yr3>2;0mAwJ z=jr}`@$)xH0}&cH5$84+J0p0SG_<0uX=z1Rwwb z2tZ&|fY1L^;+F*a!2<#ifB*y_009U<00Izz00bZaftz07j*J}L{2za#N;v<2iSGZu z=^Ka!KmY;|fB*y_009U<00Izz00bZq3XHw~e~!5M?Z{C`>yUljjH{0;GE#2*qrCT@sKydd5y9v1Hur>_wJ7DE665P$##AOHaf zKmY;|fB*z;8iBWDiu{Z6+|8!Bu-bCH1wA*CRGk?~s$@r!%F`oB+K!Q=($q+jni)w_ z()%)p2IG>|J)>#L?$I=P*Jzrwb8n^?eFi|#PC2~(pQro(#m|eM7Jo_naq+vwXT&GO zns`Qhw^$VOH_b*wpb&rn1Rwwb2tWV=5P$##AOL~u71*1Z;}h?$%Q_2&wdOSUjHK)y zN!c}$LKE(xa&jXnGb1V4k(B9?lpP}}QzI#vk(Bh<`~Oqoi*)_}dWn!90uX=z1Rwwb z2tWV=5P$##AOL}{Pl4q1f4kMNSO=f~|Mj`05ETR<009U<00Izz00bZa0SG|gH3*Eo z{x97A8cM{f5P$##AOHafKmY;|fB*y_009Vm-3#FQ|JVH{LVyr}00bZa0SG_<0uX=z z1Rwx`>lGM#{XZqXEYJ@g5P$##AOHafKmY;|fB*y_009VWLxD^tm&5h{ZMb34BnUtN z0uX=z1Rwwb2tWV=5V+L^#$Nx&{r|Umw?O+L009U<00Izz00bZa0SG`~y9@B^|Cs-8 z_pOZfK>z{}fB*y_009U<00Izzz^yL8=l@yp59t1X@vGurieD1{r}(Gz6b}eM00Izz z00bZa0SG_<0uX=z1a1O>9hp>WXO{m?PY1tu1iz<(-v5 z_%Frpp{IC200Izz00bZa0SG_<0uX=z1R!vu1#ZtAOj)IcuFES`_36*KW}6%C*)>khMIf)I3)*(GxXm~Zf2hlKE0Fv zr|h%>E#-ft(&-dmxnDx?e@+k%^>6AhtTm@ePo6Y7?G?jgvUH?$L^>i%Z|k&L9xbq2 z4U2W&J8P9@-`CHiNJ@D@(iSB7k}MsQ_|FkZk{+Lw?z*e)s-bA*x9vHe-<_vYgVQ{# zE!SI+CI3o}TD^aN*y=nFcgt4uroXyY7l&INoM`GbSs&NpAj70)O-kRYxw`MpP-_qN zT6_03Tg$iI4Qj1tI1Se{8vTu29oOP0)1=l-O5dv0Revo@t-ib0YU-xf*v2=o)vm|d zrqSETm2piDb4+U2r1Y)YTk+SX->mkw+E*yFUVF>q+8gGW)UHYCTeY|BU)gb8`|u6i zzQzh`dcEdq07l{^PMSb?Onaze8(Hvo13j>fA=koYb;+rsZEp8w`yz2 z?`Ej2bG^3SbB(srOUsQLugng!XxlnQqqlk0aV?H>O={ny^sU;hex#MYBFx>{Yxh3k zxs%TvII!jG5)Enoo}Bz@UXDjznCLdjUYnJ1ZKlOf&sv6O%pRLnO0uCEs-#x!dbO-p zWVI|y)pAv{RK2W~tBS@nrA*NqifK|l&sd?1MwOzM zB)MGT*<_Q7RTWLLZJDaq^a^EkjfO`#HJwRyrka{=YjT;HnrSK(L#77nRlQs>E3&CD zQ>oUgl2+l-sJ5!hOt-6MMK@KOO6oAP)v-=lJiytj5iYkem|m|KrMg-wE4HeZHJ(3c zicBvnW?3?Avs|)GscukRrBbPG%SJ_)<$BZ{wNj%Dj=SE36sV z@q@;1Z5wO%(BUE}Hd_{>wdt@c`0MIcdz|vo-KjM?`t9v;`=6-<2MPYsnOyEJKJ0w> zvgWJ&4|*IwPRF=>un9(++)Qrd2>6+OGe^fA0^id+ZWc!NIez={lhg@%JDxml$Lr^b zI!IkJEXAzUsT-84vTR!PLtU0SGppAbi@FMB)2Zv!4b#-*x@FPMrfYV|s+8n%rKCzx zS1Fh2xmDNe3NvKdF?8Aom8wDe2CXIQ8fEPFdfIat?=2QHnPygPje3Ausq1A))^%nZ zW`#PJVaV00RH7YHwk4@rqC8f)tlApiArrk`p1u0sr)S+(w_~!0TOHc1s!~-tJj!ep;OJ03E)E8<&P7D$MA5t&5()SGA3X%dWofsM}dTYL%#1&+xkL(ke5rEH{mM zgVBB#${Gzv)!*8uL1i=?>?oThUK8T6LpwCR`q`)GM&JUYuUic+5eq zk2ux}^W52E@0~TGtZ{94tnSh7r` zHV-2lRI3sV5Or0ikwP{Zbq1xRmyC*3RVylu+LSLGR4YY=rE1n`?4W)sYgI-g zZCz%3h@_1D?nUj?Rh33t)sSfDSLx+Nx9D}FRFbF)SyeSfRqCojDKz-gpeNaqRaTi& zE?b7d$MM1LwYYffzWa_XEgd^?;@I-?F{u>J2K%pbnl?Buq<0jr?X)+JJ;kAogS0v)S7Bn=?s`|NmV)(RMz?Y zE1XxA>6DMDm?cH7%XB(OFH*{<)lw~qX7y&Bo>(*^lgcVh z#}uoq$dVDB%PW_vB{~Ua*!(Q=d~TS`fj3{9gmQ8smE!?fwtks(uG2~H1~bz5iDu`5;HjZ|Hy71T2I zQaVdnW>tRvG3>^4Lf2+S#n5%RUZ;J*s+6g}8#; + +/** Every password user signs in with this. */ +export const FIXTURE_PASSWORD = "d4-upgrade-password"; + +/** The TOTP user's secret (base32) and 2.8 backup codes. */ +export const TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; +export const TOTP_BACKUP_CODES = ["D4BACKUP1", "D4BACKUP2", "D4BACKUP3"]; + +export const USERS = { + admin: "d4-user-admin", + alice: "d4-user-alice", + totp: "d4-user-totp", + passkey: "d4-user-passkey", + sso: "d4-user-sso", + ldap: "d4-user-ldap", +} as const; + +export const HOSTS = { + password: 1, + key: 2, + credential: 3, + none: 4, + agent: 5, + opkssh: 6, + stepca: 7, + tailscale: 8, + vault: 9, + warpgate: 10, + rdpOnly: 11, + plain: 12, + desktop: 13, +} as const; + +/** The 2.8 auth_type each host was saved with. */ +export const HOST_AUTH_TYPES: Record = { + [HOSTS.password]: "password", + [HOSTS.key]: "key", + [HOSTS.credential]: "credential", + [HOSTS.none]: "none", + [HOSTS.agent]: "agent", + [HOSTS.opkssh]: "opkssh", + [HOSTS.stepca]: "stepca", + [HOSTS.tailscale]: "tailscale", + [HOSTS.vault]: "vault", + [HOSTS.warpgate]: "warpgate", + [HOSTS.rdpOnly]: "password", + [HOSTS.plain]: "password", + [HOSTS.desktop]: "password", +}; + +/** + * 2.8 stored recording paths absolute. The fixture writes this token instead, + * and the harness swaps in the DATA_DIR it boots from, where it also copies + * the files under upgrade/files. + */ +export const DATA_DIR_TOKEN = "{{DATA_DIR}}"; +export const RECORDING_FILES = { + ssh: "session_logs/d4-user-admin/d4-session.cast", + rdp: "session_recordings/guacamole/d4-rdp.guac", +}; + +export const SSO_PROVIDER_ID = 1; +export const LDAP_PROVIDER_ID = 2; +/** 2.8's seeded user role, which the upgrade must leave as it was. */ +export const USER_ROLE_PERMISSIONS_28 = [ + "hosts.*", + "snippets.*", + "automations.*", + "credentials.*", + "ai.*", +]; +export const OPERATORS_ROLE_ID = 10; +export const OPERATORS_PERMISSIONS = [ + "hosts.view", + "credentials.view", + "snippets.*", + "automations.run", + "ai.use", +]; + +/** 2.8's electron/remote-sync-entities.cjs SYNCED_ENTITY_TYPES. */ +export const SYNCED_ENTITY_TYPES_28 = [ + "sshCredentials", + "vaultProfiles", + "sshFolders", + "snippetFolders", + "hosts", + "snippets", + "dashboardServiceLinks", + "homepageItems", + "userPreferences", +]; + +const T = "2026-05-01T10:00:00.000Z"; +const LATER = "2099-01-01T00:00:00.000Z"; + +/** + * Columns 2.8 stored encrypted with the owning user's data key, as + * [table, column, field name, column holding the user id]. The record id is + * the row id, except on users where it is the user id. + */ +export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [ + ["users", "totp_secret", "totpSecret", "id"], + ["users", "totp_backup_codes", "totpBackupCodes", "id"], + ["ssh_data", "password", "password", "user_id"], + ["ssh_data", "key", "key", "user_id"], + ["ssh_data", "key_password", "keyPassword", "user_id"], + ["ssh_data", "sudo_password", "sudoPassword", "user_id"], + ["ssh_data", "rdp_password", "rdpPassword", "user_id"], + ["ssh_credentials", "password", "password", "user_id"], + ["ssh_credentials", "private_key", "privateKey", "user_id"], + ["ssh_credentials", "key", "key", "user_id"], + ["ssh_credentials", "public_key", "publicKey", "user_id"], + ["notification_channels", "config", "config", "user_id"], + ["opkssh_tokens", "ssh_cert", "sshCert", "user_id"], + ["opkssh_tokens", "private_key", "privateKey", "user_id"], + ["vault_tokens", "ssh_cert", "sshCert", "user_id"], + ["vault_tokens", "private_key", "privateKey", "user_id"], + ["termix_identity_ca", "private_key", "privateKey", "user_id"], + ["ai_providers", "api_key", "apiKey", "user_id"], + ["secret_sources", "token", "token", "user_id"], +]; + +const PUBLIC_KEY = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGd4ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0 d4"; +const PRIVATE_KEY = + "-----BEGIN OPENSSH PRIVATE KEY-----\nd4-private-key\n-----END OPENSSH PRIVATE KEY-----"; + +function host(id: number, name: string, extra: Row = {}): Row { + return { + id, + user_id: USERS.admin, + name, + ip: `10.4.0.${id}`, + port: 22, + username: "root", + folder: "d4-folder", + tags: JSON.stringify(["d4"]), + auth_type: HOST_AUTH_TYPES[id], + sync_id: `d4-sync-host-${id}`, + created_at: T, + updated_at: T, + ...extra, + }; +} + +const HOST_ROWS: Row[] = [ + // Every plugin switch changed away from its 2.8 default. + host(HOSTS.password, "d4-password", { + password: "d4-host-password", + sudo_password: "d4-sudo", + enable_terminal: 1, + enable_command_history: 0, + enable_terminal_toolbar: 0, + enable_session_logging: 0, + allow_session_sharing: 0, + enable_file_manager: 0, + default_path: "/srv/d4", + scp_legacy: 1, + enable_tunnel: 1, + tunnel_connections: JSON.stringify([ + { + sourcePort: 8080, + endpointPort: 80, + endpointHost: "d4-key", + autoStart: false, + }, + ]), + enable_web_ui: 1, + web_ui_config: JSON.stringify({ + endpoints: [ + { id: "grafana", label: "d4-grafana", port: 3000, protocol: "http" }, + ], + }), + enable_tmux_monitor: 1, + enable_docker: 1, + docker_config: JSON.stringify({ runtime: "podman" }), + enable_ai_assistant: 1, + mac_address: "AA:BB:CC:DD:EE:04", + wol_broadcast_address: "10.4.0.255", + stats_config: JSON.stringify({ + statusCheckEnabled: false, + statusCheckInterval: 90, + metricsEnabled: false, + useGlobalMetricsInterval: false, + metricsInterval: 45, + enabledWidgets: ["cpu", "memory"], + }), + }), + host(HOSTS.key, "d4-key", { + key: PRIVATE_KEY, + key_password: "d4-key-pass", + key_type: "ssh-ed25519", + enable_proxmox: 1, + proxmox_config: JSON.stringify({ + apiUrl: "https://pve.d4.example:8006", + tokenId: "root@pam!d4", + }), + enable_proxmox_stats: 1, + proxmox_stats_config: JSON.stringify({ node: "d4-node" }), + }), + host(HOSTS.credential, "d4-credential", { credential_id: 1 }), + host(HOSTS.none, "d4-none"), + host(HOSTS.agent, "d4-agent"), + host(HOSTS.opkssh, "d4-opkssh"), + host(HOSTS.stepca, "d4-stepca"), + host(HOSTS.tailscale, "d4-tailscale"), + host(HOSTS.vault, "d4-vault", { vault_profile_id: 1 }), + host(HOSTS.warpgate, "d4-warpgate"), + // A host from before the per-protocol switches: connection_type alone made + // it an RDP host with SSH off. + host(HOSTS.rdpOnly, "d4-rdp", { + connection_type: "rdp", + rdp_user: "d4-rdp-user", + rdp_password: "d4-rdp-password", + }), + host(HOSTS.desktop, "d4-desktop", { + enable_rdp: 1, + enable_vnc: 1, + enable_telnet: 1, + rdp_port: 3390, + vnc_port: 5901, + telnet_port: 2323, + rdp_user: "d4-rdp-user", + rdp_password: "d4-rdp-password", + rdp_security: "nla", + rdp_ignore_cert: 1, + guacamole_config: JSON.stringify({ colorDepth: 24 }), + }), + // Touches nothing, so it has to keep reading 2.8's defaults. + host(HOSTS.plain, "d4-plain"), +]; + +export const ROWS: Record = { + users: [ + { id: USERS.admin, username: "d4-admin", is_admin: 1 }, + { id: USERS.alice, username: "d4-alice" }, + { + id: USERS.totp, + username: "d4-totp", + totp_enabled: 1, + totp_secret: TOTP_SECRET, + totp_backup_codes: JSON.stringify(TOTP_BACKUP_CODES), + }, + { id: USERS.passkey, username: "d4-passkey" }, + { + id: USERS.sso, + username: "d4-sso", + password_hash: "", + is_oidc: 1, + oidc_identifier: "d4-oidc-subject", + sso_provider_id: SSO_PROVIDER_ID, + }, + { + id: USERS.ldap, + username: "d4-ldap", + password_hash: "", + is_oidc: 1, + oidc_identifier: `ldap:${LDAP_PROVIDER_ID}:d4-ldap-dn`, + sso_provider_id: LDAP_PROVIDER_ID, + }, + ].map((user) => ({ registered_at: T, ...user })), + + roles: [ + { + id: 1, + name: "admin", + display_name: "rbac.roles.admin", + description: "Administrator with full access", + is_system: 1, + permissions: JSON.stringify(["*"]), + }, + { + id: 2, + name: "user", + display_name: "rbac.roles.user", + description: "Regular user", + is_system: 1, + permissions: JSON.stringify(USER_ROLE_PERMISSIONS_28), + }, + { + id: OPERATORS_ROLE_ID, + name: "d4-operators", + display_name: "D4 operators", + is_system: 0, + permissions: JSON.stringify(OPERATORS_PERMISSIONS), + }, + ], + user_roles: [ + { user_id: USERS.admin, role_id: 1 }, + ...[USERS.alice, USERS.totp, USERS.passkey, USERS.sso, USERS.ldap].map( + (userId) => ({ user_id: userId, role_id: 2 }), + ), + { + user_id: USERS.alice, + role_id: OPERATORS_ROLE_ID, + granted_by: USERS.admin, + }, + ], + + ssh_credentials: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-credential", + auth_type: "key", + username: "deploy", + private_key: PRIVATE_KEY, + public_key: PUBLIC_KEY, + key_type: "ssh-ed25519", + sync_id: "d4-sync-credential", + }, + ], + ssh_folders: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-folder", + sync_id: "d4-sync-folder", + }, + ], + vault_profiles: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-vault", + vault_addr: "https://vault.d4.example", + ssh_mount: "ssh", + ssh_role: "admin", + shared: 0, + sync_id: "d4-sync-vault", + }, + ], + ssh_data: HOST_ROWS, + host_access: [ + { + id: 1, + host_id: HOSTS.password, + user_id: USERS.alice, + granted_by: USERS.admin, + permission_level: "connect", + }, + ], + user_preferences: [ + { + user_id: USERS.admin, + theme: "dark", + ai_assistant_enabled: 1, + ai_read_only_commands: 1, + rdp_defaults: JSON.stringify({ + colorDepth: 16, + resizeMethod: "reconnect", + enableDrive: true, + }), + }, + ], + sso_providers: [ + { + id: SSO_PROVIDER_ID, + name: "d4-sso", + type: "oidc", + enabled: 1, + display_order: 0, + config: JSON.stringify({ + issuerUrl: "https://id.d4.example", + clientId: "termix", + clientSecret: "d4-client-secret", + }), + }, + { + id: LDAP_PROVIDER_ID, + name: "d4-ldap", + type: "ldap", + enabled: 1, + display_order: 1, + config: JSON.stringify({ + url: "ldaps://ldap.d4.example", + bindDn: "cn=termix,dc=d4", + bindPassword: "d4-bind", + searchBase: "dc=d4", + }), + }, + ], + webauthn_credentials: [ + { + id: "d4-passkey-id", + user_id: USERS.passkey, + name: "d4-passkey", + credential_id: "ZDQtY3JlZGVudGlhbA", + public_key: "ZDQtcHVibGljLWtleQ", + counter: 3, + transports: JSON.stringify(["internal"]), + }, + ], + notification_channels: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-channel", + type: "ntfy", + config: JSON.stringify({ url: "https://ntfy.d4.example/d4" }), + enabled: 1, + }, + ], + + snippet_folders: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-snippet-folder", + sync_id: "d4-sync-snippet-folder", + }, + ], + snippets: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-snippet", + content: "uptime", + folder: "d4-snippet-folder", + sync_id: "d4-sync-snippet", + }, + { + id: 2, + user_id: USERS.admin, + name: "d4-shared-snippet", + content: "df -h", + sync_id: "d4-sync-snippet-shared", + }, + ], + snippet_access: [ + { + id: 1, + snippet_id: 2, + user_id: USERS.alice, + granted_by: USERS.admin, + permission_level: "view", + }, + ], + + fleets: [{ id: 1, user_id: USERS.admin, name: "d4-fleet" }], + fleet_members: [ + { id: 1, fleet_id: 1, host_id: HOSTS.password }, + { id: 2, fleet_id: 1, host_id: HOSTS.key }, + ], + fleet_inventory: [ + { + id: 1, + host_id: HOSTS.password, + user_id: USERS.admin, + os_pretty_name: "d4 Linux", + hostname: "d4-password", + }, + ], + + automations: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-automation", + enabled: 0, + definition: JSON.stringify({ + version: 1, + trigger: { type: "manual" }, + steps: [], + }), + }, + ], + automation_trigger_state: [ + { id: 1, automation_id: 1, state_key: "d4-state", last_value: 1 }, + ], + automation_schedules: [ + { id: 1, automation_id: 1, cron: "0 4 * * *", timezone: "UTC" }, + ], + automation_runs: [ + { + id: 1, + automation_id: 1, + user_id: USERS.admin, + trigger_type: "manual", + status: "success", + started_at: T, + finished_at: T, + }, + ], + automation_run_steps: [ + { + id: 1, + run_id: 1, + step_index: 0, + step_id: "d4-step", + step_type: "command", + status: "success", + output: "d4-output", + }, + ], + automation_channels: [{ id: 1, automation_id: 1, channel_id: 1 }], + + user_workspaces: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-workspace", + payload: JSON.stringify({}), + sync_id: "d4-sync-workspace", + }, + ], + network_topology: [ + { + id: 1, + user_id: USERS.admin, + topology: JSON.stringify({ nodes: [{ id: "d4-node" }], edges: [] }), + }, + ], + + homepage_items: [ + { + id: 1, + user_id: USERS.admin, + type_id: "clock", + title: "d4-widget", + config: JSON.stringify({}), + sync_id: "d4-sync-homepage-item", + }, + ], + homepage_layouts: [ + { + id: 1, + user_id: USERS.admin, + layout: JSON.stringify([{ i: "1", x: 0, y: 0, w: 2, h: 2 }]), + }, + ], + dashboard_service_links: [ + { + id: 1, + user_id: USERS.admin, + label: "d4-link", + url: "https://d4.example", + sync_id: "d4-sync-service-link", + }, + ], + + vault_tokens: [ + { + id: 1, + user_id: USERS.admin, + profile_id: 1, + ssh_cert: "d4-vault-cert", + private_key: PRIVATE_KEY, + expires_at: LATER, + }, + ], + opkssh_tokens: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.opkssh, + ssh_cert: "d4-opkssh-cert", + private_key: PRIVATE_KEY, + email: "d4@example.com", + expires_at: LATER, + }, + ], + + session_recordings: [ + { + id: 1, + host_id: HOSTS.password, + user_id: USERS.admin, + username: "d4-admin", + started_at: T, + ended_at: T, + duration: 12, + recording_path: `${DATA_DIR_TOKEN}/${RECORDING_FILES.ssh}`, + protocol: "ssh", + format: "asciicast", + }, + { + id: 2, + host_id: HOSTS.desktop, + user_id: USERS.admin, + username: "d4-admin", + started_at: T, + ended_at: T, + duration: 30, + recording_path: `${DATA_DIR_TOKEN}/${RECORDING_FILES.rdp}`, + protocol: "rdp", + format: "guacamole", + }, + ], + command_history: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + command: "d4-history-command", + executed_at: T, + }, + ], + c2s_tunnel_presets: [ + { + id: 1, + user_id: USERS.admin, + name: "d4-preset", + config: JSON.stringify({ tunnels: [] }), + }, + ], + + ai_providers: [ + { + id: 1, + user_id: USERS.admin, + provider_type: "openai", + label: "d4-provider", + api_key: "sk-d4-provider-key", + api_key_prefix: "sk-d4", + default_model: "gpt-d4", + enabled: 1, + }, + ], + ai_conversations: [ + { id: 1, user_id: USERS.admin, title: "d4-conversation", provider_id: 1 }, + ], + ai_messages: [ + { id: 1, conversation_id: 1, role: "user", content: "d4-message" }, + ], + ai_proposals: [ + { + id: 1, + conversation_id: 1, + user_id: USERS.admin, + kind: "run_command", + summary: "d4-proposal", + payload: JSON.stringify({ command: "uptime" }), + }, + ], + + secret_sources: [ + { + id: "d4-source", + user_id: USERS.admin, + name: "d4-source", + kind: "vaultwarden", + base_url: "https://vw.d4.example", + token: "d4-source-token", + }, + ], + + termix_identities: [{ id: 1, user_id: USERS.admin, handle: "d4-handle" }], + termix_identity_keys: [ + { + id: 1, + identity_id: 1, + user_id: USERS.admin, + public_key: PUBLIC_KEY, + key_type: "ssh-ed25519", + algorithm: "ed25519", + label: "d4-identity-key", + }, + ], + termix_identity_ca: [ + { + id: 1, + identity_id: 1, + user_id: USERS.admin, + public_key: PUBLIC_KEY, + private_key: PRIVATE_KEY, + }, + ], + + host_metrics_preferences: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + layout: JSON.stringify({ d4: true }), + }, + ], + host_health_checks: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + checks: JSON.stringify([{ id: "d4-check", type: "tcp", port: 22 }]), + }, + ], + host_health_history: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + check_id: "d4-check", + ts: T, + ok: 1, + latency_ms: 4, + }, + ], + host_metrics_history: [ + { id: 1, host_id: HOSTS.password, ts: T, cpu_percent: 44 }, + ], + proxmox_stats_preferences: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.key, + layout: JSON.stringify({ d4: true }), + }, + ], + proxmox_node_history: [{ id: 1, host_id: HOSTS.key, ts: T, cpu_percent: 4 }], + + session_shares: [ + { + id: "d4-share", + host_id: HOSTS.password, + owner_user_id: USERS.admin, + protocol: "ssh", + session_id: "d4-session", + share_type: "user", + target_user_id: USERS.alice, + permission_level: "read", + expires_at: LATER, + }, + ], + session_share_participants: [ + { id: 1, share_id: "d4-share", user_id: USERS.alice, joined_at: T }, + ], + collab_rooms: [ + { + id: "d4-room", + name: "d4-room", + owner_user_id: USERS.admin, + persistent: 1, + }, + ], + collab_room_members: [ + { id: 1, room_id: "d4-room", user_id: USERS.admin, room_role: "owner" }, + { id: 2, room_id: "d4-room", user_id: USERS.alice, added_by: USERS.admin }, + ], + + file_manager_recent: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + name: "d4-recent", + path: "/srv/d4/recent", + }, + ], + file_manager_pinned: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + name: "d4-pinned", + path: "/srv/d4/pinned", + }, + ], + file_manager_shortcuts: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + name: "d4-shortcut", + path: "/srv/d4/shortcut", + }, + ], + transfer_recent: [ + { + id: 1, + user_id: USERS.admin, + source_host_id: HOSTS.password, + dest_host_id: HOSTS.key, + dest_path: "/srv/d4/transfer", + dest_path_label: "d4-transfer", + }, + ], + tmux_session_tags: [ + { + id: 1, + user_id: USERS.admin, + host_id: HOSTS.password, + session_name: "d4-tmux", + tag: "d4-tag", + }, + ], + + settings: [ + ["tailscale_api_key", "tskey-d4"], + ["tailscale_api_base_url", "https://headscale.d4.example"], + ["guac_enabled", "true"], + ["guac_url", "guacd.d4.example:4822"], + ["global_metrics_interval", "40"], + ["metrics_history_retention_days", "14"], + [ + "host_defaults", + JSON.stringify({ metricsEnabled: false, enableCommandHistory: false }), + ], + ["ai_globally_enabled", "true"], + ["ai_private_endpoint_allowlist", JSON.stringify(["10.4.0.0/24"])], + [ + "acme_ssl_settings", + JSON.stringify({ + enabled: true, + domain: "termix.d4.example", + email: "d4@example.com", + challengeType: "cloudflare", + cloudflareToken: "d4-cloudflare-token", + }), + ], + ["step_ca_url", "https://ca.d4.example"], + ["step_ca_fingerprint", "d4fingerprint"], + ["step_ca_provisioner", "d4-provisioner"], + ["terminal_session_timeout_minutes", "45"], + ["terminal_session_persistence_enabled", "false"], + ["terminal_image_max_count", "7"], + ["session_sharing_globally_enabled", "false"], + // Long enough that the fixture's recordings never age out. + ["session_recording_retention_days", "3650"], + ].map(([key, value]) => ({ key, value })), +}; + +/** The 2.8 core tables the fixture fills that stay core in 2.9.0. */ +export const CORE_TABLES = new Set([ + "users", + "roles", + "user_roles", + "ssh_credentials", + "ssh_folders", + "ssh_data", + "host_access", + "user_preferences", + "notification_channels", + "settings", +]); diff --git a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql new file mode 100644 index 000000000..a46c1c277 --- /dev/null +++ b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql @@ -0,0 +1,1136 @@ +-- Termix 2.8.0 to 2.9.0 on mysql, generated by scripts/lib/upgrade-sql.mjs + +-- ==== core 0024_thin_colleen_wing ==== +CREATE TABLE `plugin_storage` ( + `id` int AUTO_INCREMENT NOT NULL, + `plugin_id` varchar(255) NOT NULL, + `key` varchar(255) NOT NULL, + `value` text NOT NULL, + `updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `plugin_storage_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_plugin_storage_plugin_key` UNIQUE(`plugin_id`,`key`) +); +--> statement-breakpoint +ALTER TABLE `plugin_storage` ADD CONSTRAINT `plugin_storage_plugin_id_plugins_id_fk` FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON DELETE cascade ON UPDATE no action; + +-- ==== core 0025_wet_master_mold ==== +DROP TABLE `alert_firings`;--> statement-breakpoint +DROP TABLE `alert_rule_channels`;--> statement-breakpoint +DROP TABLE `alert_rules`; + +-- ==== core 0026_empty_black_bird ==== +ALTER TABLE `plugin_permission_grants` MODIFY COLUMN `granted_by` varchar(255);--> statement-breakpoint +ALTER TABLE `plugin_permission_grants` ADD `source` text DEFAULT ('admin') NOT NULL;--> statement-breakpoint +ALTER TABLE `plugins` ADD `last_error` text; + +-- ==== core 0027_watery_multiple_man ==== +CREATE TABLE `plugin_migrations` ( + `id` int AUTO_INCREMENT NOT NULL, + `plugin_id` varchar(255) NOT NULL, + `migration_id` varchar(255) NOT NULL, + `checksum` text NOT NULL, + `applied_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `plugin_migrations_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_plugin_migrations_plugin_migration` UNIQUE(`plugin_id`,`migration_id`) +); +--> statement-breakpoint +ALTER TABLE `plugin_migrations` ADD CONSTRAINT `plugin_migrations_plugin_id_plugins_id_fk` FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON DELETE cascade ON UPDATE no action; + +-- ==== core 0028_fair_tusk ==== +CREATE TABLE `rbac_applied_defaults` ( + `id` int AUTO_INCREMENT NOT NULL, + `role_name` varchar(255) NOT NULL, + `permission` varchar(255) NOT NULL, + `applied_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `rbac_applied_defaults_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_rbac_applied_defaults_role_permission` UNIQUE(`role_name`,`permission`) +); +--> statement-breakpoint +CREATE TABLE `rbac_known_permissions` ( + `id` int AUTO_INCREMENT NOT NULL, + `permission` varchar(255) NOT NULL, + `plugin_id` varchar(255), + `first_seen_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `rbac_known_permissions_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_rbac_known_permissions_permission` UNIQUE(`permission`) +); + +-- ==== core 0029_open_captain_america ==== +CREATE TABLE `plugin_settings` ( + `id` int AUTO_INCREMENT NOT NULL, + `plugin_id` varchar(255) NOT NULL, + `scope` varchar(255) NOT NULL, + `scope_id` varchar(255), + `key` varchar(255) NOT NULL, + `value` text, + `encrypted` boolean NOT NULL DEFAULT false, + `updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `plugin_settings_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_plugin_settings_scope_key` UNIQUE(`plugin_id`,`scope`,`scope_id`,`key`) +); +--> 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`); + +-- ==== core 0030_sweet_warlock ==== +CREATE TABLE `user_external_identities` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `provider_id` varchar(255) NOT NULL, + `subject` varchar(255) NOT NULL, + `email` text, + `created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_external_identities_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_user_external_identities_provider_subject` UNIQUE(`provider_id`,`subject`) +); +--> statement-breakpoint +CREATE TABLE `user_second_factors` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `plugin_id` varchar(255) NOT NULL, + `factor_id` varchar(255) NOT NULL, + `enrolled_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_second_factors_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_user_second_factors_user_factor` UNIQUE(`user_id`,`plugin_id`,`factor_id`) +); +--> statement-breakpoint +ALTER TABLE `user_external_identities` ADD CONSTRAINT `user_external_identities_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_second_factors` ADD CONSTRAINT `user_second_factors_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_user_external_identities_user` ON `user_external_identities` (`user_id`); + +-- ==== core 0031_nebulous_diamondback ==== +-- user_workspaces moved to the workspaces plugin, whose first migration renames it +-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace, +-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an +-- empty query. +SELECT 1; + +-- ==== core 0032_lethal_marrow ==== +-- network_topology moved to the network-topology plugin, which adopts (renames) +-- this table in its own migration before this drizzle migration would run. +-- No DROP here: it would delete every row before the plugin's rename applies. +SELECT 1; + +-- ==== core 0033_clear_anthem ==== +-- snippets, snippet_folders and snippet_access moved to the snippets plugin +-- (p_snippets_*), adopted by rename rather than dropped. A real DROP TABLE +-- here would run before the plugin's adoption migration and delete every row. +SELECT 1; + +-- ==== core 0034_green_vector ==== +-- dropped, see plugin adoption (fleets plugin, migrations/mysql/0001_adopt_fleets.sql) +SELECT 1; + +-- ==== core 0035_clumsy_leech ==== +-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox +-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four +-- ssh_data proxmox columns are copied into the plugin's host settings after +-- plugins load, which is after this migration, so a real DROP COLUMN here +-- would delete the data first. The columns stay in place, unused, until 3.0.0. +SELECT 1; + +-- ==== core 0036_dear_vapor ==== +-- file_manager_pinned, file_manager_recent, file_manager_shortcuts and +-- transfer_recent are adopted (renamed, not dropped) by the file-manager +-- plugin's own migration, which runs at plugin activation, after core's +-- drizzle migrations. A DROP TABLE here would run first and delete the data. +SELECT 1; + +-- ==== core 0037_foamy_norman_osborn ==== +-- c2s_tunnel_presets moved to the tunnels plugin, whose first migration renames +-- it to p_tunnels_presets. Dropping it here would lose every saved preset, so +-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an +-- empty query. +SELECT 1; + +-- ==== core 0038_quiet_karnak ==== +-- command_history moved to the ssh-terminal plugin, whose first migration +-- renames it to p_ssh_terminal_command_history. Dropping it here would lose +-- every saved command, so this only stops drizzle tracking it. SELECT 1 +-- because MySQL rejects an empty query. +SELECT 1; + +-- ==== core 0039_condemned_shiva ==== +-- tmux_session_tags moved to the tmux-monitor plugin, whose first migration +-- renames it to p_tmux_monitor_session_tags. Dropping it here would lose +-- every saved tag, so this only stops drizzle tracking it. SELECT 1 because +-- MySQL rejects an empty query. +SELECT 1; + +-- ==== core 0040_misty_skin ==== +-- session_shares, session_share_participants, collab_rooms and +-- collab_room_members moved to the session-sharing plugin, whose first +-- migration renames them. Dropping them here would lose every share and +-- room, so this only stops drizzle tracking them. SELECT 1 because MySQL +-- rejects an empty query. +SELECT 1; + +-- ==== core 0041_wonderful_cargill ==== +-- session_recordings moved to the session-recording plugin, whose first +-- migration renames it. Dropping it here would lose every recording, so +-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an +-- empty query. +SELECT 1; + +-- ==== core 0042_colorful_bill_hollister ==== +-- The remote desktop host options (enable_rdp, enable_vnc, enable_telnet, +-- the three ports, rdp_security, rdp_ignore_cert, security, ignore_cert, +-- guacamole_config) and user_preferences.rdp_defaults moved to the +-- remote-desktop plugin's settings. The copy runs after plugins load, which +-- is after this migration, so a real DROP COLUMN here would delete the data +-- first. The columns stay in place, unused, until 3.0.0. +SELECT 1; + +-- ==== core 0043_unusual_lucky_pierre ==== +-- host_metrics_preferences, host_health_checks, host_health_history and +-- host_metrics_history moved to the host-metrics plugin, which adopts them by +-- rename in its own first migration. That runs after this one, so a real +-- DROP TABLE here would delete every row first. +SELECT 1;--> statement-breakpoint +-- Status checks are core: the per-host switch and interval, copied out of +-- stats_config at boot by host-status-config-migration.ts. +ALTER TABLE `ssh_data` ADD `status_check_enabled` boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `status_check_interval` int; + +-- ==== core 0044_jittery_thunderball ==== +-- ssh_data.stats_config split: its status half moved to status_check_enabled +-- and status_check_interval, its metrics half to the host-metrics plugin's +-- host settings. Both copies run at boot, after this migration, so a real +-- DROP COLUMN here would delete the data first. The column stays in place, +-- unused, until 3.0.0. +SELECT 1; + +-- ==== core 0045_flowery_the_hood ==== +-- ssh_data.enable_docker and docker_config moved to the docker plugin's host +-- settings. The copy runs at boot, after this migration, so a real DROP +-- COLUMN here would delete the data first. The columns stay in place, +-- unused, until 3.0.0. +SELECT 1; + +-- ==== core 0046_rich_logan ==== +-- The automation tables moved to the automations plugin, which renames them +-- into its own namespace at activation. That runs after this migration, so a +-- real DROP TABLE here would delete every automation first. +SELECT 1; + +-- ==== core 0047_red_silver_sable ==== +-- The ai tables moved to the ai plugin, which renames them into its own +-- namespace at activation, and ssh_data.enable_ai_assistant and the two +-- user_preferences ai columns moved to its settings. Both run after this +-- migration, so a real DROP here would delete the data first. The columns +-- stay in place, unused, until 3.0.0. The timestamp and label type changes +-- drizzle-kit also wrote are generator drift (see FINISH-LIST.md), not a +-- change to the database, and are left out on purpose. +SELECT 1; + +-- ==== core 0048_hard_demogoblin ==== +-- dashboard_service_links, homepage_items and homepage_layouts moved to the +-- homepage plugin, which adopts them by rename at activation (see +-- plugins/homepage/migrations/mysql/0001_adopt_homepage_tables.sql). +-- Dropping them here would delete every row before the plugin's migration +-- ever runs, since this runs at boot before the plugins table exists. +SELECT 1; + +-- ==== core 0049_quiet_blacklash ==== +-- mac_address and wol_broadcast_address move to the wake-on-lan plugin's +-- host settings at boot, after this migration runs. Dropping them here would +-- delete the data before the copy has a chance to run. +SELECT 1; + +-- ==== core 0050_optimal_speedball ==== +-- secret_sources moves to the secret-sources plugin's adopted table at +-- activation, after this migration runs. Dropping it here would delete the +-- data before the plugin has a chance to rename it. +SELECT 1; + +-- ==== core 0051_icy_hercules ==== +-- webauthn_credentials moves to the webauthn plugin's adopted table at +-- activation, and the users TOTP columns are read by the TOTP boot migration +-- after this runs. Dropping either here would lose the data first. +SELECT 1; + +-- ==== core 0052_illegal_gladiator ==== +-- sso_providers moves to the sso plugin's adopted table at activation, and +-- its LDAP rows are copied into the ldap plugin by a boot migration after +-- this runs. Dropping it here would lose the data first. +SELECT 1; + +-- ==== core 0053_magical_groot ==== +-- opkssh_tokens moves to the opkssh plugin's adopted table at activation, +-- and ssh_data.use_warpgate is copied into the warpgate plugin's host +-- settings by a boot migration after this runs. Dropping either here would +-- lose the data first. +SELECT 1; + +-- ==== core 0054_wakeful_misty_knight ==== +-- vault_profiles and vault_tokens move to the vault plugin's adopted tables +-- at activation, and ssh_data.vault_profile_id is copied into the vault +-- plugin's host settings by a boot migration after this runs. Dropping any +-- of them here would lose the data first. +SELECT 1; + +-- ==== core 0055_icy_patch ==== +-- termix_identities, termix_identity_keys and termix_identity_ca move to the +-- termix-identity plugin's adopted tables at activation. Dropping them here +-- would lose the data first. +SELECT 1; + +-- ==== core 0056_supreme_toad_men ==== +-- The dialect generator now decides varchar or text per table. Columns that +-- were varchar(255) only because the same name was a key in another table +-- become text, and the timestamps and labels B18's regeneration left as +-- varchar(255) on real databases follow. varchar to text keeps every value. +ALTER TABLE `api_keys` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint +ALTER TABLE `api_keys` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `api_keys` MODIFY COLUMN `expires_at` text;--> statement-breakpoint +ALTER TABLE `credential_access` MODIFY COLUMN `expires_at` text;--> statement-breakpoint +ALTER TABLE `credential_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `credential_sidebar_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `folder_access` MODIFY COLUMN `expires_at` text;--> statement-breakpoint +ALTER TABLE `folder_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `host_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `host_sidebar_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `ssh_data` MODIFY COLUMN `name` text;--> statement-breakpoint +ALTER TABLE `ssh_data` MODIFY COLUMN `folder` text;--> statement-breakpoint +ALTER TABLE `ssh_data` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `ssh_data` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `notification_channels` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint +ALTER TABLE `notification_channels` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `plugin_install_counts` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `plugin_registries` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint +ALTER TABLE `plugin_settings` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `plugin_storage` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `plugins` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint +ALTER TABLE `plugins` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `rbac_known_permissions` MODIFY COLUMN `plugin_id` text;--> statement-breakpoint +ALTER TABLE `roles` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `roles` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `sessions` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `shared_credential_secrets` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `shared_credential_secrets` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `shared_host_auth_overrides` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `shared_host_auth_overrides` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `shared_host_secrets` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `shared_host_secrets` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `ssh_credentials` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint +ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` text;--> statement-breakpoint +ALTER TABLE `ssh_credentials` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `ssh_credentials` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `ssh_folders` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint +ALTER TABLE `ssh_folders` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `ssh_folders` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `sync_tombstones` MODIFY COLUMN `sync_id` text NOT NULL;--> statement-breakpoint +ALTER TABLE `trusted_devices` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `trusted_devices` MODIFY COLUMN `expires_at` text NOT NULL;--> statement-breakpoint +ALTER TABLE `ui_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `user_external_identities` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `user_open_tabs` MODIFY COLUMN `label` text NOT NULL;--> statement-breakpoint +ALTER TABLE `user_open_tabs` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `user_open_tabs` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint +ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP); + +-- ==== core 0057_serious_shatterstar ==== +-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session +-- sharing and session recording host columns, and the dead show_*_in_sidebar +-- columns, moved to their plugins' host settings. The copies run after +-- plugins load, which is after this migration, so a real DROP COLUMN here +-- would delete the data first. The columns stay in place, unused, until 3.0.0. +SELECT 1; + +-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== +-- workspaces 0001: adopt_user_workspaces +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `user_workspaces` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `kind` text NOT NULL DEFAULT ('manual'), + `is_default` boolean NOT NULL DEFAULT false, + `payload` text NOT NULL DEFAULT ('{}'), + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `user_workspaces` TO `p_workspaces_workspaces`; + +-- ==== plugin network-topology 0001_adopt_network_topology.sql ==== +-- network-topology 0001: adopt_network_topology +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `network_topology` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `topology` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `network_topology` TO `p_network_topology_graphs`; + +-- ==== plugin snippets 0001_adopt_snippets_tables.sql ==== +-- snippets 0001: adopt_snippets_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `snippets` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `content` text NOT NULL, + `description` text, + `folder` text, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `host_filter` text, + `is_note` boolean NOT NULL DEFAULT false, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `snippets` TO `p_snippets_snippets`; +CREATE TABLE IF NOT EXISTS `snippet_folders` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `snippet_folders` TO `p_snippets_snippet_folders`; +CREATE TABLE IF NOT EXISTS `snippet_access` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `snippet_id` int NOT NULL, + `user_id` varchar(255), + `role_id` int, + `granted_by` varchar(255) NOT NULL, + `permission_level` text NOT NULL DEFAULT ('view'), + `expires_at` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`granted_by`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `snippet_access` TO `p_snippets_snippet_access`; + +-- ==== plugin fleets 0001_adopt_fleets.sql ==== +-- fleets 0001: adopt_fleets +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `fleets` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `description` text, + `color` text, + `icon` text, + `tag_rules` text, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `fleets` TO `p_fleets_fleets`; +CREATE TABLE IF NOT EXISTS `fleet_members` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `fleet_id` int NOT NULL, + `host_id` int NOT NULL, + `added_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`fleet_id`) REFERENCES `p_fleets_fleets` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `fleet_members` TO `p_fleets_members`; +CREATE TABLE IF NOT EXISTS `fleet_inventory` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `host_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `os_pretty_name` text, + `kernel` text, + `architecture` text, + `hostname` text, + `uptime_seconds` int, + `ip` text, + `package_manager` text, + `collected_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `fleet_inventory` TO `p_fleets_inventory`; + +-- ==== plugin ssh-terminal 0001_adopt_command_history.sql ==== +-- ssh-terminal 0001: adopt_command_history +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `command_history` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `command` text NOT NULL, + `executed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `command_history` TO `p_ssh_terminal_command_history`; + +-- ==== plugin tunnels 0001_adopt_c2s_tunnel_presets.sql ==== +-- tunnels 0001: adopt_c2s_tunnel_presets +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `c2s_tunnel_presets` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `config` text NOT NULL, + `platform` text, + `computer_name` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `c2s_tunnel_presets` TO `p_tunnels_presets`; + +-- ==== plugin host-metrics 0001_adopt_host_metrics_tables.sql ==== +-- host-metrics 0001: adopt_host_metrics_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `host_metrics_preferences` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `layout` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `host_metrics_preferences` TO `p_host_metrics_host_metrics_preferences`; +CREATE TABLE IF NOT EXISTS `host_health_checks` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `checks` text NOT NULL, + `interval_seconds` int NOT NULL DEFAULT 300, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `host_health_checks` TO `p_host_metrics_host_health_checks`; +CREATE TABLE IF NOT EXISTS `host_health_history` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `check_id` varchar(255) NOT NULL, + `ts` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ok` boolean NOT NULL, + `latency_ms` int, + `detail` text, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `host_health_history` TO `p_host_metrics_host_health_history`; +CREATE TABLE IF NOT EXISTS `host_metrics_history` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `host_id` int NOT NULL, + `ts` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `cpu_percent` double, + `mem_percent` double, + `disk_percent` double, + `net_rx_bytes` int, + `net_tx_bytes` int, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `host_metrics_history` TO `p_host_metrics_host_metrics_history`; + +-- ==== plugin automations 0001_adopt_automations.sql ==== +-- automations 0001: adopt_automations +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare +-- (links between these tables, and a channel link to notification_channels). + +CREATE TABLE IF NOT EXISTS `automations` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `description` text, + `enabled` boolean NOT NULL DEFAULT true, + `definition` text NOT NULL, + `definition_version` int NOT NULL DEFAULT 1, + `concurrency_policy` text NOT NULL DEFAULT ('skip'), + `max_run_seconds` int NOT NULL DEFAULT 300, + `dry_run` boolean NOT NULL DEFAULT false, + `last_run_at` text, + `last_run_status` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `automations` TO `p_automations_automations`; +CREATE TABLE IF NOT EXISTS `automation_trigger_state` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `automation_id` int NOT NULL, + `state_key` varchar(255) NOT NULL, + `breach_started_at` text, + `last_fired_at` text, + `last_value` double, + `last_observed_state` text, + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `automation_trigger_state` TO `p_automations_trigger_state`; +CREATE TABLE IF NOT EXISTS `automation_schedules` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `automation_id` int NOT NULL, + `cron` text, + `interval_seconds` int, + `timezone` text, + `next_due_at` varchar(255), + `last_tick_at` text, + FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `automation_schedules` TO `p_automations_schedules`; +CREATE TABLE IF NOT EXISTS `automation_runs` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `automation_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `trigger_type` text NOT NULL, + `trigger_context` text, + `status` text NOT NULL, + `started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `finished_at` text, + `duration_ms` int, + `error` text, + `dry_run` boolean NOT NULL DEFAULT false, + `parent_run_id` int, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `automation_runs` TO `p_automations_runs`; +CREATE TABLE IF NOT EXISTS `automation_run_steps` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `run_id` int NOT NULL, + `step_index` int NOT NULL, + `step_id` text NOT NULL, + `step_type` text NOT NULL, + `status` text NOT NULL, + `started_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `finished_at` text, + `output` text, + `error` text, + `truncated` boolean NOT NULL DEFAULT false, + FOREIGN KEY (`run_id`) REFERENCES `p_automations_runs` (`id`) ON DELETE CASCADE +); +RENAME TABLE `automation_run_steps` TO `p_automations_run_steps`; +CREATE TABLE IF NOT EXISTS `automation_channels` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `automation_id` int NOT NULL, + `channel_id` int NOT NULL, + FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`channel_id`) REFERENCES `notification_channels` (`id`) ON DELETE CASCADE +); +RENAME TABLE `automation_channels` TO `p_automations_channels`; + +-- ==== plugin homepage 0001_adopt_homepage_tables.sql ==== +-- homepage 0001: adopt_homepage_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `homepage_items` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `type_id` text NOT NULL, + `title` text, + `config` text NOT NULL DEFAULT ('{}'), + `folder_id` int, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `homepage_items` TO `p_homepage_homepage_items`; +CREATE TABLE IF NOT EXISTS `homepage_layouts` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL UNIQUE, + `layout` text NOT NULL DEFAULT ('{}'), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `homepage_layouts` TO `p_homepage_homepage_layouts`; +CREATE TABLE IF NOT EXISTS `dashboard_service_links` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `label` text NOT NULL, + `url` text NOT NULL, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `dashboard_service_links` TO `p_homepage_dashboard_service_links`; + +-- ==== plugin ai 0001_adopt_ai_tables.sql ==== +-- ai 0001: adopt_ai_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign key from messages and proposals to +-- their conversation, which the SDK cannot declare. + +CREATE TABLE IF NOT EXISTS `ai_providers` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `provider_type` text NOT NULL, + `label` varchar(255) NOT NULL, + `base_url` text, + `api_key_prefix` text, + `default_model` text, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_providers` TO `p_ai_providers`; +CREATE TABLE IF NOT EXISTS `ai_conversations` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `title` text, + `provider_id` int, + `model` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_conversations` TO `p_ai_conversations`; +CREATE TABLE IF NOT EXISTS `ai_messages` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `conversation_id` int NOT NULL, + `role` text NOT NULL, + `content` text NOT NULL DEFAULT (''), + `tool_calls` text, + `created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`conversation_id`) REFERENCES `p_ai_conversations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_messages` TO `p_ai_messages`; +CREATE TABLE IF NOT EXISTS `ai_proposals` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `conversation_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `kind` varchar(255) NOT NULL, + `summary` text, + `payload` text NOT NULL DEFAULT ('{}'), + `status` varchar(32) NOT NULL DEFAULT 'pending', + `applied_at` text, + `result_summary` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`conversation_id`) REFERENCES `p_ai_conversations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_proposals` TO `p_ai_proposals`; + +-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ==== +-- file-manager 0001: adopt_file_manager_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `file_manager_recent` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `last_opened` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `file_manager_recent` TO `p_file_manager_recent`; +CREATE TABLE IF NOT EXISTS `file_manager_pinned` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `pinned_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `file_manager_pinned` TO `p_file_manager_pinned`; +CREATE TABLE IF NOT EXISTS `file_manager_shortcuts` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `file_manager_shortcuts` TO `p_file_manager_shortcuts`; +CREATE TABLE IF NOT EXISTS `transfer_recent` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `source_host_id` int NOT NULL, + `dest_host_id` int NOT NULL, + `dest_path` text NOT NULL, + `dest_path_label` text NOT NULL, + `last_used` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`source_host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`dest_host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `transfer_recent` TO `p_file_manager_transfer_recent`; + +-- ==== plugin ldap 0001_create_providers.sql ==== +-- ldap 0001: create_providers +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `p_ldap_providers` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `name` text NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `display_order` int NOT NULL DEFAULT 0, + `config` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); + +-- ==== plugin opkssh 0001_adopt_opkssh_tokens.sql ==== +-- opkssh 0001: adopt_opkssh_tokens +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `opkssh_tokens` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `email` text, + `sub` text, + `issuer` text, + `audience` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` varchar(255) NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `opkssh_tokens` TO `p_opkssh_tokens`; + +-- ==== plugin proxmox 0001_adopt_proxmox_stats.sql ==== +-- proxmox 0001: adopt_proxmox_stats +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `proxmox_node_history` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `host_id` int NOT NULL, + `ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `cpu_percent` double, + `mem_percent` double, + `disk_percent` double, + `net_rx_bytes` int, + `net_tx_bytes` int, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `proxmox_node_history` TO `p_proxmox_node_history`; +CREATE TABLE IF NOT EXISTS `proxmox_stats_preferences` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `layout` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `proxmox_stats_preferences` TO `p_proxmox_stats_preferences`; + +-- ==== plugin session-recording 0001_adopt_session_recordings.sql ==== +-- session-recording 0001: adopt_session_recordings +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `session_recordings` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `host_id` int NOT NULL, + `user_id` varchar(255), + `username` text, + `access_id` int, + `started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ended_at` text, + `duration` int, + `commands` text, + `dangerous_actions` text, + `recording_path` text, + `protocol` text NOT NULL DEFAULT ('ssh'), + `format` text NOT NULL DEFAULT ('text'), + `terminated_by_owner` boolean DEFAULT false, + `termination_reason` text, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `session_recordings` TO `p_session_recording_session_recordings`; + +-- ==== plugin secret-sources 0001_adopt_secret_sources.sql ==== +-- secret-sources 0001: adopt_secret_sources +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `secret_sources` ( + `id` varchar(255) PRIMARY KEY NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `kind` text NOT NULL DEFAULT ('onepassword-connect'), + `base_url` text NOT NULL, + `shared` boolean NOT NULL DEFAULT false, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `secret_sources` TO `p_secret_sources_sources`; + +-- ==== plugin session-sharing 0001_adopt_session_sharing.sql ==== +-- session-sharing 0001: adopt_session_sharing +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare +-- (nullable or set-null user and host links, and links between these tables). + +CREATE TABLE IF NOT EXISTS `session_shares` ( + `id` varchar(255) PRIMARY KEY NOT NULL, + `host_id` int NOT NULL, + `owner_user_id` varchar(255) NOT NULL, + `protocol` text NOT NULL, + `session_id` varchar(255) NOT NULL, + `tab_instance_id` text, + `share_type` text NOT NULL, + `target_user_id` varchar(255), + `link_token` varchar(255) UNIQUE, + `permission_level` text NOT NULL DEFAULT ('read-only'), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `revoked_at` text, + `last_joined_at` text, + `join_count` int NOT NULL DEFAULT 0, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`target_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `session_shares` TO `p_session_sharing_shares`; +CREATE TABLE IF NOT EXISTS `session_share_participants` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `share_id` varchar(255) NOT NULL, + `user_id` varchar(255), + `guest_label` text, + `joined_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `left_at` text, + FOREIGN KEY (`share_id`) REFERENCES `p_session_sharing_shares` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `session_share_participants` TO `p_session_sharing_share_participants`; +CREATE TABLE IF NOT EXISTS `collab_rooms` ( + `id` varchar(255) PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `owner_user_id` varchar(255) NOT NULL, + `persistent` boolean NOT NULL DEFAULT false, + `presenter_user_id` varchar(255), + `stage_protocol` text, + `stage_host_id` int, + `stage_share_id` varchar(255), + `guest_link_token` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ended_at` text, + FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`presenter_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data` (`id`) ON DELETE SET NULL, + FOREIGN KEY (`stage_share_id`) REFERENCES `p_session_sharing_shares` (`id`) ON DELETE SET NULL +); +RENAME TABLE `collab_rooms` TO `p_session_sharing_rooms`; +CREATE TABLE IF NOT EXISTS `collab_room_members` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `room_id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `room_role` text NOT NULL DEFAULT ('member'), + `added_by` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`room_id`) REFERENCES `p_session_sharing_rooms` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`added_by`) REFERENCES `users` (`id`) ON DELETE SET NULL +); +RENAME TABLE `collab_room_members` TO `p_session_sharing_room_members`; + +-- ==== plugin sso 0001_adopt_sso_providers.sql ==== +-- sso 0001: adopt_sso_providers +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `sso_providers` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `name` text NOT NULL, + `type` text NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `display_order` int NOT NULL DEFAULT 0, + `config` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); +RENAME TABLE `sso_providers` TO `p_sso_providers`; + +-- ==== plugin sso 0002_legacy_callback.sql ==== +-- sso 0002: legacy_callback +-- Generated by termix-plugin migrations. Review before committing. + +ALTER TABLE `p_sso_providers` ADD COLUMN `legacy_callback` boolean NOT NULL DEFAULT false; +-- Providers that existed before 2.9 were registered with the identity +-- provider under /users/oidc/callback, so they keep sending that URI. +UPDATE `p_sso_providers` SET `legacy_callback` = true; + +-- ==== plugin step-ca 0001_create_certs.sql ==== +-- step-ca 0001: create_certs +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `p_step_ca_certs` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `email` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` varchar(255) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +CREATE UNIQUE INDEX `idx_step_ca_certs_user_host` ON `p_step_ca_certs` (`user_id`, `host_id`); + +-- ==== plugin termix-identity 0001_adopt_termix_identity_tables.sql ==== +-- termix-identity 0001: adopt_termix_identity_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare +-- (keys and ca to their identity, keys to ssh_credentials). + +CREATE TABLE IF NOT EXISTS `termix_identities` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL UNIQUE, + `handle` varchar(255) NOT NULL UNIQUE, + `description` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `termix_identities` TO `p_termix_identity_identities`; +CREATE TABLE IF NOT EXISTS `termix_identity_keys` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `identity_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `public_key` text NOT NULL, + `key_type` text NOT NULL, + `algorithm` text NOT NULL, + `label` text, + `comment` text, + `source` text NOT NULL DEFAULT ('manual'), + `credential_id` int, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`identity_id`) REFERENCES `p_termix_identity_identities` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials` (`id`) ON DELETE SET NULL +); +RENAME TABLE `termix_identity_keys` TO `p_termix_identity_keys`; +CREATE TABLE IF NOT EXISTS `termix_identity_ca` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `identity_id` int NOT NULL UNIQUE, + `user_id` varchar(255) NOT NULL, + `public_key` text NOT NULL, + `private_key` text NOT NULL, + `validity_days` int NOT NULL DEFAULT 90, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`identity_id`) REFERENCES `p_termix_identity_identities` (`id`) ON DELETE CASCADE +); +RENAME TABLE `termix_identity_ca` TO `p_termix_identity_ca`; + +-- ==== plugin tmux-monitor 0001_adopt_tmux_session_tags.sql ==== +-- tmux-monitor 0001: adopt_tmux_session_tags +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `tmux_session_tags` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `session_name` text NOT NULL, + `tag` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `tmux_session_tags` TO `p_tmux_monitor_session_tags`; + +-- ==== plugin totp 0001_create_enrollments.sql ==== +-- totp 0001: create_enrollments +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `p_totp_enrollments` ( + `user_id` varchar(255) PRIMARY KEY NOT NULL, + `secret` text, + `pending_secret` text, + `backup_codes` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); + +-- ==== plugin vault 0001_adopt_vault_tables.sql ==== +-- vault 0001: adopt_vault_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `vault_profiles` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `description` text, + `folder` text, + `tags` text, + `vault_addr` text NOT NULL, + `vault_namespace` text, + `oidc_mount` text, + `oidc_role` text, + `ssh_mount` text, + `ssh_role` text NOT NULL, + `valid_principals` text, + `key_type` text, + `shared` boolean NOT NULL DEFAULT false, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `vault_profiles` TO `p_vault_profiles`; +CREATE TABLE IF NOT EXISTS `vault_tokens` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `profile_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` varchar(255) NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `vault_tokens` TO `p_vault_tokens`; + +-- ==== plugin webauthn 0001_adopt_webauthn_credentials.sql ==== +-- webauthn 0001: adopt_webauthn_credentials +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `webauthn_credentials` ( + `id` varchar(255) PRIMARY KEY NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `credential_id` varchar(255) NOT NULL UNIQUE, + `public_key` text NOT NULL, + `counter` int NOT NULL DEFAULT 0, + `device_type` text, + `backed_up` boolean NOT NULL DEFAULT false, + `transports` text, + `user_verification` text NOT NULL DEFAULT ('preferred'), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `webauthn_credentials` TO `p_webauthn_credentials`; diff --git a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql new file mode 100644 index 000000000..764baed68 --- /dev/null +++ b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql @@ -0,0 +1,1208 @@ +-- Termix 2.8.0 to 2.9.0 on postgres, generated by scripts/lib/upgrade-sql.mjs + +-- ==== core 0025_purple_squirrel_girl ==== +CREATE TABLE "plugin_storage" ( + "id" serial PRIMARY KEY NOT NULL, + "plugin_id" varchar(255) NOT NULL, + "key" varchar(255) NOT NULL, + "value" text NOT NULL, + "updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "plugin_storage" ADD CONSTRAINT "plugin_storage_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_plugin_storage_plugin_key" ON "plugin_storage" USING btree ("plugin_id","key"); + +-- ==== core 0026_perfect_masque ==== +DROP TABLE "alert_firings" CASCADE;--> statement-breakpoint +DROP TABLE "alert_rule_channels" CASCADE;--> statement-breakpoint +DROP TABLE "alert_rules" CASCADE; + +-- ==== core 0027_sharp_guardian ==== +ALTER TABLE "plugin_permission_grants" ALTER COLUMN "granted_by" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "plugin_permission_grants" ADD COLUMN "source" text DEFAULT 'admin' NOT NULL;--> statement-breakpoint +ALTER TABLE "plugins" ADD COLUMN "last_error" text; + +-- ==== core 0028_whole_zemo ==== +CREATE TABLE "plugin_migrations" ( + "id" serial PRIMARY KEY NOT NULL, + "plugin_id" varchar(255) NOT NULL, + "migration_id" varchar(255) NOT NULL, + "checksum" text NOT NULL, + "applied_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "plugin_migrations" ADD CONSTRAINT "plugin_migrations_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_plugin_migrations_plugin_migration" ON "plugin_migrations" USING btree ("plugin_id","migration_id"); + +-- ==== core 0029_nosy_roulette ==== +CREATE TABLE "rbac_applied_defaults" ( + "id" serial PRIMARY KEY NOT NULL, + "role_name" varchar(255) NOT NULL, + "permission" varchar(255) NOT NULL, + "applied_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rbac_known_permissions" ( + "id" serial PRIMARY KEY NOT NULL, + "permission" varchar(255) NOT NULL, + "plugin_id" varchar(255), + "first_seen_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "idx_rbac_applied_defaults_role_permission" ON "rbac_applied_defaults" USING btree ("role_name","permission");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_rbac_known_permissions_permission" ON "rbac_known_permissions" USING btree ("permission"); + +-- ==== core 0030_cynical_callisto ==== +CREATE TABLE "plugin_settings" ( + "id" serial PRIMARY KEY NOT NULL, + "plugin_id" varchar(255) NOT NULL, + "scope" varchar(255) NOT NULL, + "scope_id" varchar(255), + "key" varchar(255) NOT NULL, + "value" text, + "encrypted" boolean DEFAULT false NOT NULL, + "updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "plugin_settings" ADD CONSTRAINT "plugin_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_plugin_settings_scope_key" ON "plugin_settings" USING btree ("plugin_id","scope","scope_id","key");--> statement-breakpoint +CREATE INDEX "idx_plugin_settings_plugin_scope" ON "plugin_settings" USING btree ("plugin_id","scope"); + +-- ==== core 0031_nifty_sentry ==== +CREATE TABLE "user_external_identities" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "provider_id" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL, + "email" text, + "created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_second_factors" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "plugin_id" varchar(255) NOT NULL, + "factor_id" varchar(255) NOT NULL, + "enrolled_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_external_identities" ADD CONSTRAINT "user_external_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_second_factors" ADD CONSTRAINT "user_second_factors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_user_external_identities_provider_subject" ON "user_external_identities" USING btree ("provider_id","subject");--> statement-breakpoint +CREATE INDEX "idx_user_external_identities_user" ON "user_external_identities" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_user_second_factors_user_factor" ON "user_second_factors" USING btree ("user_id","plugin_id","factor_id"); + +-- ==== core 0032_nifty_hedge_knight ==== +-- user_workspaces moved to the workspaces plugin, whose first migration renames it +-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace, +-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an +-- empty query. +SELECT 1; + +-- ==== core 0033_bent_trish_tilby ==== +-- network_topology moved to the network-topology plugin, which adopts (renames) +-- this table in its own migration before this drizzle migration would run. +-- No DROP here: it would delete every row before the plugin's rename applies. +SELECT 1; + +-- ==== core 0034_free_smiling_tiger ==== +-- snippets, snippet_folders and snippet_access moved to the snippets plugin +-- (p_snippets_*), adopted by rename rather than dropped. A real DROP TABLE +-- here would run before the plugin's adoption migration and delete every row. +SELECT 1; + +-- ==== core 0035_ambitious_klaw ==== +-- dropped, see plugin adoption (fleets plugin, migrations/postgres/0001_adopt_fleets.sql) +SELECT 1; + +-- ==== core 0036_redundant_ender_wiggin ==== +-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox +-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four +-- ssh_data proxmox columns are copied into the plugin's host settings after +-- plugins load, which is after this migration, so a real DROP COLUMN here +-- would delete the data first. The columns stay in place, unused, until 3.0.0. +SELECT 1; + +-- ==== core 0037_dizzy_sage ==== +-- file_manager_pinned, file_manager_recent, file_manager_shortcuts and +-- transfer_recent are adopted (renamed, not dropped) by the file-manager +-- plugin's own migration, which runs at plugin activation, after core's +-- drizzle migrations. A DROP TABLE here would run first and delete the data. +SELECT 1; + +-- ==== core 0038_cute_vermin ==== +-- c2s_tunnel_presets moved to the tunnels plugin, whose first migration renames +-- it to p_tunnels_presets. Dropping it here would lose every saved preset, so +-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an +-- empty query. +SELECT 1; + +-- ==== core 0039_violet_firedrake ==== +-- command_history moved to the ssh-terminal plugin, whose first migration +-- renames it to p_ssh_terminal_command_history. Dropping it here would lose +-- every saved command, so this only stops drizzle tracking it. SELECT 1 +-- because MySQL rejects an empty query. +SELECT 1; + +-- ==== core 0040_narrow_hellion ==== +-- tmux_session_tags moved to the tmux-monitor plugin, whose first migration +-- renames it to p_tmux_monitor_session_tags. Dropping it here would lose +-- every saved tag, so this only stops drizzle tracking it. SELECT 1 because +-- MySQL rejects an empty query. +SELECT 1; + +-- ==== core 0041_watery_zeigeist ==== +-- session_shares, session_share_participants, collab_rooms and +-- collab_room_members moved to the session-sharing plugin, whose first +-- migration renames them. Dropping them here would lose every share and +-- room, so this only stops drizzle tracking them. SELECT 1 because MySQL +-- rejects an empty query. +SELECT 1; + +-- ==== core 0042_strange_shocker ==== +-- session_recordings moved to the session-recording plugin, whose first +-- migration renames it. Dropping it here would lose every recording, so +-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an +-- empty query. +SELECT 1; + +-- ==== core 0043_melodic_terror ==== +-- The remote desktop host options (enable_rdp, enable_vnc, enable_telnet, +-- the three ports, rdp_security, rdp_ignore_cert, security, ignore_cert, +-- guacamole_config) and user_preferences.rdp_defaults moved to the +-- remote-desktop plugin's settings. The copy runs after plugins load, which +-- is after this migration, so a real DROP COLUMN here would delete the data +-- first. The columns stay in place, unused, until 3.0.0. +SELECT 1; + +-- ==== core 0044_nostalgic_omega_sentinel ==== +-- host_metrics_preferences, host_health_checks, host_health_history and +-- host_metrics_history moved to the host-metrics plugin, which adopts them by +-- rename in its own first migration. That runs after this one, so a real +-- DROP TABLE here would delete every row first. +SELECT 1;--> statement-breakpoint +-- Status checks are core: the per-host switch and interval, copied out of +-- stats_config at boot by host-status-config-migration.ts. +ALTER TABLE "ssh_data" ADD COLUMN "status_check_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD COLUMN "status_check_interval" integer; + +-- ==== core 0045_thankful_shinobi_shaw ==== +-- ssh_data.stats_config split: its status half moved to status_check_enabled +-- and status_check_interval, its metrics half to the host-metrics plugin's +-- host settings. Both copies run at boot, after this migration, so a real +-- DROP COLUMN here would delete the data first. The column stays in place, +-- unused, until 3.0.0. +SELECT 1; + +-- ==== core 0046_small_white_tiger ==== +-- ssh_data.enable_docker and docker_config moved to the docker plugin's host +-- settings. The copy runs at boot, after this migration, so a real DROP +-- COLUMN here would delete the data first. The columns stay in place, +-- unused, until 3.0.0. +SELECT 1; + +-- ==== core 0047_mighty_butterfly ==== +-- The automation tables moved to the automations plugin, which renames them +-- into its own namespace at activation. That runs after this migration, so a +-- real DROP TABLE here would delete every automation first. +SELECT 1; + +-- ==== core 0048_rare_catseye ==== +-- The ai tables moved to the ai plugin, which renames them into its own +-- namespace at activation, and ssh_data.enable_ai_assistant and the two +-- user_preferences ai columns moved to its settings. Both run after this +-- migration, so a real DROP here would delete the data first. The columns +-- stay in place, unused, until 3.0.0. The timestamp and label type changes +-- drizzle-kit also wrote are generator drift (see FINISH-LIST.md), not a +-- change to the database, and are left out on purpose. +SELECT 1; + +-- ==== core 0049_reflective_adam_destine ==== +-- dashboard_service_links, homepage_items and homepage_layouts moved to the +-- homepage plugin, which adopts them by rename at activation (see +-- plugins/homepage/migrations/postgres/0001_adopt_homepage_tables.sql). +-- Dropping them here would delete every row before the plugin's migration +-- ever runs, since this runs at boot before the plugins table exists. +SELECT 1; + +-- ==== core 0050_acoustic_lady_bullseye ==== +-- mac_address and wol_broadcast_address move to the wake-on-lan plugin's +-- host settings at boot, after this migration runs. Dropping them here would +-- delete the data before the copy has a chance to run. +SELECT 1; + +-- ==== core 0051_useful_slyde ==== +-- secret_sources moves to the secret-sources plugin's adopted table at +-- activation, after this migration runs. Dropping it here would delete the +-- data before the plugin has a chance to rename it. +SELECT 1; + +-- ==== core 0052_motionless_krista_starr ==== +-- webauthn_credentials moves to the webauthn plugin's adopted table at +-- activation, and the users TOTP columns are read by the TOTP boot migration +-- after this runs. Dropping either here would lose the data first. +SELECT 1; + +-- ==== core 0053_keen_thanos ==== +-- sso_providers moves to the sso plugin's adopted table at activation, and +-- its LDAP rows are copied into the ldap plugin by a boot migration after +-- this runs. Dropping it here would lose the data first. +SELECT 1; + +-- ==== core 0054_cloudy_galactus ==== +-- opkssh_tokens moves to the opkssh plugin's adopted table at activation, +-- and ssh_data.use_warpgate is copied into the warpgate plugin's host +-- settings by a boot migration after this runs. Dropping either here would +-- lose the data first. +SELECT 1; + +-- ==== core 0055_jazzy_quasimodo ==== +-- vault_profiles and vault_tokens move to the vault plugin's adopted tables +-- at activation, and ssh_data.vault_profile_id is copied into the vault +-- plugin's host settings by a boot migration after this runs. Dropping any +-- of them here would lose the data first. +SELECT 1; + +-- ==== core 0056_chilly_vindicator ==== +-- termix_identities, termix_identity_keys and termix_identity_ca move to the +-- termix-identity plugin's adopted tables at activation. Dropping them here +-- would lose the data first. +SELECT 1; + +-- ==== core 0057_married_dragon_lord ==== +-- The dialect generator now decides varchar or text per table. Columns that +-- were varchar(255) only because the same name was a key in another table +-- become text, and the timestamps and labels B18's regeneration left as +-- varchar(255) on real databases follow. varchar to text keeps every value. +ALTER TABLE "api_keys" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "api_keys" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "api_keys" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "api_keys" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "credential_access" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "credential_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "credential_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "credential_sidebar_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "credential_sidebar_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "folder_access" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "folder_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "folder_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "host_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "host_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "host_sidebar_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "host_sidebar_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "ssh_data" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_data" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_data" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "ssh_data" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_data" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "notification_channels" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "notification_channels" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "notification_channels" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "plugin_install_counts" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "plugin_install_counts" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "plugin_registries" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "plugin_settings" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "plugin_settings" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "plugin_storage" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "plugin_storage" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "plugins" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "rbac_known_permissions" ALTER COLUMN "plugin_id" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "roles" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "roles" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "roles" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "roles" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "sessions" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "sessions" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "shared_credential_secrets" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "shared_credential_secrets" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "shared_credential_secrets" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "shared_credential_secrets" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "ssh_folders" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_folders" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_folders" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "ssh_folders" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ssh_folders" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "sync_tombstones" ALTER COLUMN "sync_id" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "trusted_devices" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "trusted_devices" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "trusted_devices" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ui_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "ui_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "user_external_identities" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "user_external_identities" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ALTER COLUMN "label" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint +ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- ==== core 0058_goofy_ma_gnuci ==== +-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session +-- sharing and session recording host columns, and the dead show_*_in_sidebar +-- columns, moved to their plugins' host settings. The copies run after +-- plugins load, which is after this migration, so a real DROP COLUMN here +-- would delete the data first. The columns stay in place, unused, until 3.0.0. +SELECT 1; + +-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== +-- workspaces 0001: adopt_user_workspaces +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "user_workspaces" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "color" text, + "icon" text, + "kind" text NOT NULL DEFAULT 'manual', + "is_default" boolean NOT NULL DEFAULT false, + "payload" text NOT NULL DEFAULT '{}', + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id"); +ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces"; + +-- ==== plugin network-topology 0001_adopt_network_topology.sql ==== +-- network-topology 0001: adopt_network_topology +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "network_topology" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "topology" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "network_topology" RENAME TO "p_network_topology_graphs"; + +-- ==== plugin snippets 0001_adopt_snippets_tables.sql ==== +-- snippets 0001: adopt_snippets_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "snippets" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "content" text NOT NULL, + "description" text, + "folder" text, + "order" integer NOT NULL DEFAULT 0, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "host_filter" text, + "is_note" boolean NOT NULL DEFAULT false, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_snippets_user_id" ON "snippets" ("user_id"); +ALTER TABLE "snippets" RENAME TO "p_snippets_snippets"; +CREATE TABLE IF NOT EXISTS "snippet_folders" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "color" text, + "icon" text, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "snippet_folders" RENAME TO "p_snippets_snippet_folders"; +CREATE TABLE IF NOT EXISTS "snippet_access" ( + "id" serial PRIMARY KEY, + "snippet_id" integer NOT NULL, + "user_id" varchar(255), + "role_id" integer, + "granted_by" varchar(255) NOT NULL, + "permission_level" text NOT NULL DEFAULT 'view', + "expires_at" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("granted_by") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_user_id" ON "snippet_access" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_snippet_id" ON "snippet_access" ("snippet_id"); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_role_id" ON "snippet_access" ("role_id"); +ALTER TABLE "snippet_access" RENAME TO "p_snippets_snippet_access"; + +-- ==== plugin fleets 0001_adopt_fleets.sql ==== +-- fleets 0001: adopt_fleets +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "fleets" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "description" text, + "color" text, + "icon" text, + "tag_rules" text, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"; +CREATE TABLE IF NOT EXISTS "fleet_members" ( + "id" serial PRIMARY KEY, + "fleet_id" integer NOT NULL, + "host_id" integer NOT NULL, + "added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id"); +CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id"); +ALTER TABLE "fleet_members" RENAME TO "p_fleets_members"; +CREATE TABLE IF NOT EXISTS "fleet_inventory" ( + "id" serial PRIMARY KEY, + "host_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "os_pretty_name" text, + "kernel" text, + "architecture" text, + "hostname" text, + "uptime_seconds" integer, + "ip" text, + "package_manager" text, + "collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id"); +CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id"); +ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory"; + +-- ==== plugin ssh-terminal 0001_adopt_command_history.sql ==== +-- ssh-terminal 0001: adopt_command_history +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "command_history" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "command" text NOT NULL, + "executed_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_command_history_user_host" ON "command_history" ("user_id", "host_id"); +ALTER TABLE "command_history" RENAME TO "p_ssh_terminal_command_history"; + +-- ==== plugin tunnels 0001_adopt_c2s_tunnel_presets.sql ==== +-- tunnels 0001: adopt_c2s_tunnel_presets +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "c2s_tunnel_presets" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "config" text NOT NULL, + "platform" text, + "computer_name" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "c2s_tunnel_presets" RENAME TO "p_tunnels_presets"; + +-- ==== plugin host-metrics 0001_adopt_host_metrics_tables.sql ==== +-- host-metrics 0001: adopt_host_metrics_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "host_metrics_preferences" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "layout" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" ("user_id", "host_id"); +ALTER TABLE "host_metrics_preferences" RENAME TO "p_host_metrics_host_metrics_preferences"; +CREATE TABLE IF NOT EXISTS "host_health_checks" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "checks" text NOT NULL, + "interval_seconds" integer NOT NULL DEFAULT 300, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_health_checks_user_host" ON "host_health_checks" ("user_id", "host_id"); +ALTER TABLE "host_health_checks" RENAME TO "p_host_metrics_host_health_checks"; +CREATE TABLE IF NOT EXISTS "host_health_history" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "check_id" varchar(255) NOT NULL, + "ts" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ok" boolean NOT NULL, + "latency_ms" integer, + "detail" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_host_health_history_lookup" ON "host_health_history" ("user_id", "host_id", "check_id", "ts"); +ALTER TABLE "host_health_history" RENAME TO "p_host_metrics_host_health_history"; +CREATE TABLE IF NOT EXISTS "host_metrics_history" ( + "id" serial PRIMARY KEY, + "host_id" integer NOT NULL, + "ts" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "cpu_percent" double precision, + "mem_percent" double precision, + "disk_percent" double precision, + "net_rx_bytes" integer, + "net_tx_bytes" integer, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_host_metrics_history_host_ts" ON "host_metrics_history" ("host_id", "ts"); +ALTER TABLE "host_metrics_history" RENAME TO "p_host_metrics_host_metrics_history"; + +-- ==== plugin automations 0001_adopt_automations.sql ==== +-- automations 0001: adopt_automations +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare +-- (links between these tables, and a channel link to notification_channels). + +CREATE TABLE IF NOT EXISTS "automations" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "description" text, + "enabled" boolean NOT NULL DEFAULT true, + "definition" text NOT NULL, + "definition_version" integer NOT NULL DEFAULT 1, + "concurrency_policy" text NOT NULL DEFAULT 'skip', + "max_run_seconds" integer NOT NULL DEFAULT 300, + "dry_run" boolean NOT NULL DEFAULT false, + "last_run_at" text, + "last_run_status" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled"); +ALTER TABLE "automations" RENAME TO "p_automations_automations"; +CREATE TABLE IF NOT EXISTS "automation_trigger_state" ( + "id" serial PRIMARY KEY, + "automation_id" integer NOT NULL, + "state_key" varchar(255) NOT NULL, + "breach_started_at" text, + "last_fired_at" text, + "last_value" double precision, + "last_observed_state" text, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key"); +ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state"; +CREATE TABLE IF NOT EXISTS "automation_schedules" ( + "id" serial PRIMARY KEY, + "automation_id" integer NOT NULL, + "cron" text, + "interval_seconds" integer, + "timezone" text, + "next_due_at" varchar(255), + "last_tick_at" text, + FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id"); +CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at"); +ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules"; +CREATE TABLE IF NOT EXISTS "automation_runs" ( + "id" serial PRIMARY KEY, + "automation_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "trigger_type" text NOT NULL, + "trigger_context" text, + "status" text NOT NULL, + "started_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finished_at" text, + "duration_ms" integer, + "error" text, + "dry_run" boolean NOT NULL DEFAULT false, + "parent_run_id" integer, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at"); +CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at"); +ALTER TABLE "automation_runs" RENAME TO "p_automations_runs"; +CREATE TABLE IF NOT EXISTS "automation_run_steps" ( + "id" serial PRIMARY KEY, + "run_id" integer NOT NULL, + "step_index" integer NOT NULL, + "step_id" text NOT NULL, + "step_type" text NOT NULL, + "status" text NOT NULL, + "started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finished_at" text, + "output" text, + "error" text, + "truncated" boolean NOT NULL DEFAULT false, + FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index"); +ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps"; +CREATE TABLE IF NOT EXISTS "automation_channels" ( + "id" serial PRIMARY KEY, + "automation_id" integer NOT NULL, + "channel_id" integer NOT NULL, + FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE, + FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id"); +ALTER TABLE "automation_channels" RENAME TO "p_automations_channels"; + +-- ==== plugin homepage 0001_adopt_homepage_tables.sql ==== +-- homepage 0001: adopt_homepage_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "homepage_items" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "type_id" text NOT NULL, + "title" text, + "config" text NOT NULL DEFAULT '{}', + "folder_id" integer, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_homepage_items_user_id" ON "homepage_items" ("user_id"); +ALTER TABLE "homepage_items" RENAME TO "p_homepage_homepage_items"; +CREATE TABLE IF NOT EXISTS "homepage_layouts" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL UNIQUE, + "layout" text NOT NULL DEFAULT '{}', + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "homepage_layouts" RENAME TO "p_homepage_homepage_layouts"; +CREATE TABLE IF NOT EXISTS "dashboard_service_links" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "label" text NOT NULL, + "url" text NOT NULL, + "order" integer NOT NULL DEFAULT 0, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_dashboard_service_links_user_id" ON "dashboard_service_links" ("user_id"); +ALTER TABLE "dashboard_service_links" RENAME TO "p_homepage_dashboard_service_links"; + +-- ==== plugin ai 0001_adopt_ai_tables.sql ==== +-- ai 0001: adopt_ai_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign key from messages and proposals to +-- their conversation, which the SDK cannot declare. + +CREATE TABLE IF NOT EXISTS "ai_providers" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "provider_type" text NOT NULL, + "label" varchar(255) NOT NULL, + "base_url" text, + "api_key_prefix" text, + "default_model" text, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_providers_user_label" ON "ai_providers" ("user_id", "label"); +ALTER TABLE "ai_providers" RENAME TO "p_ai_providers"; +CREATE TABLE IF NOT EXISTS "ai_conversations" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "title" text, + "provider_id" integer, + "model" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_conversations_user" ON "ai_conversations" ("user_id", "updated_at"); +ALTER TABLE "ai_conversations" RENAME TO "p_ai_conversations"; +CREATE TABLE IF NOT EXISTS "ai_messages" ( + "id" serial PRIMARY KEY, + "conversation_id" integer NOT NULL, + "role" text NOT NULL, + "content" text NOT NULL DEFAULT '', + "tool_calls" text, + "created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_messages_conversation" ON "ai_messages" ("conversation_id", "created_at"); +ALTER TABLE "ai_messages" RENAME TO "p_ai_messages"; +CREATE TABLE IF NOT EXISTS "ai_proposals" ( + "id" serial PRIMARY KEY, + "conversation_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "kind" varchar(255) NOT NULL, + "summary" text, + "payload" text NOT NULL DEFAULT '{}', + "status" varchar(32) NOT NULL DEFAULT 'pending', + "applied_at" text, + "result_summary" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id", "status"); +CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id"); +ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals"; + +-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ==== +-- file-manager 0001: adopt_file_manager_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "file_manager_recent" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" text NOT NULL, + "path" text NOT NULL, + "last_opened" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_file_manager_recent_user" ON "file_manager_recent" ("user_id", "host_id"); +ALTER TABLE "file_manager_recent" RENAME TO "p_file_manager_recent"; +CREATE TABLE IF NOT EXISTS "file_manager_pinned" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" text NOT NULL, + "path" text NOT NULL, + "pinned_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_file_manager_pinned_user" ON "file_manager_pinned" ("user_id", "host_id"); +ALTER TABLE "file_manager_pinned" RENAME TO "p_file_manager_pinned"; +CREATE TABLE IF NOT EXISTS "file_manager_shortcuts" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" text NOT NULL, + "path" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_file_manager_shortcuts_user" ON "file_manager_shortcuts" ("user_id", "host_id"); +ALTER TABLE "file_manager_shortcuts" RENAME TO "p_file_manager_shortcuts"; +CREATE TABLE IF NOT EXISTS "transfer_recent" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "source_host_id" integer NOT NULL, + "dest_host_id" integer NOT NULL, + "dest_path" text NOT NULL, + "dest_path_label" text NOT NULL, + "last_used" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("source_host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE, + FOREIGN KEY ("dest_host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_transfer_recent_user" ON "transfer_recent" ("user_id"); +ALTER TABLE "transfer_recent" RENAME TO "p_file_manager_transfer_recent"; + +-- ==== plugin ldap 0001_create_providers.sql ==== +-- ldap 0001: create_providers +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "p_ldap_providers" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL, + "enabled" boolean NOT NULL DEFAULT true, + "display_order" integer NOT NULL DEFAULT 0, + "config" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- ==== plugin opkssh 0001_adopt_opkssh_tokens.sql ==== +-- opkssh 0001: adopt_opkssh_tokens +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "opkssh_tokens" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "email" text, + "sub" text, + "issuer" text, + "audience" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" varchar(255) NOT NULL, + "last_used" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_opkssh_tokens_user_host" ON "opkssh_tokens" ("user_id", "host_id"); +ALTER TABLE "opkssh_tokens" RENAME TO "p_opkssh_tokens"; + +-- ==== plugin proxmox 0001_adopt_proxmox_stats.sql ==== +-- proxmox 0001: adopt_proxmox_stats +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "proxmox_node_history" ( + "id" serial PRIMARY KEY, + "host_id" integer NOT NULL, + "ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "cpu_percent" double precision, + "mem_percent" double precision, + "disk_percent" double precision, + "net_rx_bytes" integer, + "net_tx_bytes" integer, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +ALTER TABLE "proxmox_node_history" RENAME TO "p_proxmox_node_history"; +CREATE TABLE IF NOT EXISTS "proxmox_stats_preferences" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "layout" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_proxmox_stats_prefs_user_host" ON "proxmox_stats_preferences" ("user_id", "host_id"); +ALTER TABLE "proxmox_stats_preferences" RENAME TO "p_proxmox_stats_preferences"; + +-- ==== plugin session-recording 0001_adopt_session_recordings.sql ==== +-- session-recording 0001: adopt_session_recordings +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "session_recordings" ( + "id" serial PRIMARY KEY, + "host_id" integer NOT NULL, + "user_id" varchar(255), + "username" text, + "access_id" integer, + "started_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" text, + "duration" integer, + "commands" text, + "dangerous_actions" text, + "recording_path" text, + "protocol" text NOT NULL DEFAULT 'ssh', + "format" text NOT NULL DEFAULT 'text', + "terminated_by_owner" boolean DEFAULT false, + "termination_reason" text, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_session_recordings_user_started" ON "session_recordings" ("user_id", "started_at"); +CREATE INDEX IF NOT EXISTS "idx_session_recordings_host" ON "session_recordings" ("host_id"); +ALTER TABLE "session_recordings" RENAME TO "p_session_recording_session_recordings"; + +-- ==== plugin secret-sources 0001_adopt_secret_sources.sql ==== +-- secret-sources 0001: adopt_secret_sources +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "secret_sources" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "kind" text NOT NULL DEFAULT 'onepassword-connect', + "base_url" text NOT NULL, + "shared" boolean NOT NULL DEFAULT false, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_secret_sources_user" ON "secret_sources" ("user_id"); +ALTER TABLE "secret_sources" RENAME TO "p_secret_sources_sources"; + +-- ==== plugin session-sharing 0001_adopt_session_sharing.sql ==== +-- session-sharing 0001: adopt_session_sharing +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare +-- (nullable or set-null user and host links, and links between these tables). + +CREATE TABLE IF NOT EXISTS "session_shares" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "owner_user_id" varchar(255) NOT NULL, + "protocol" text NOT NULL, + "session_id" varchar(255) NOT NULL, + "tab_instance_id" text, + "share_type" text NOT NULL, + "target_user_id" varchar(255), + "link_token" varchar(255) UNIQUE, + "permission_level" text NOT NULL DEFAULT 'read-only', + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" text NOT NULL, + "revoked_at" text, + "last_joined_at" text, + "join_count" integer NOT NULL DEFAULT 0, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE, + FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("target_user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_session_shares_session_id" ON "session_shares" ("session_id"); +CREATE INDEX IF NOT EXISTS "idx_session_shares_host_id" ON "session_shares" ("host_id"); +ALTER TABLE "session_shares" RENAME TO "p_session_sharing_shares"; +CREATE TABLE IF NOT EXISTS "session_share_participants" ( + "id" serial PRIMARY KEY, + "share_id" varchar(255) NOT NULL, + "user_id" varchar(255), + "guest_label" text, + "joined_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "left_at" text, + FOREIGN KEY ("share_id") REFERENCES "p_session_sharing_shares" ("id") ON DELETE CASCADE, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "session_share_participants" RENAME TO "p_session_sharing_share_participants"; +CREATE TABLE IF NOT EXISTS "collab_rooms" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "owner_user_id" varchar(255) NOT NULL, + "persistent" boolean NOT NULL DEFAULT false, + "presenter_user_id" varchar(255), + "stage_protocol" text, + "stage_host_id" integer, + "stage_share_id" varchar(255), + "guest_link_token" varchar(255), + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" text, + FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("presenter_user_id") REFERENCES "users" ("id") ON DELETE SET NULL, + FOREIGN KEY ("stage_host_id") REFERENCES "ssh_data" ("id") ON DELETE SET NULL, + FOREIGN KEY ("stage_share_id") REFERENCES "p_session_sharing_shares" ("id") ON DELETE SET NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_collab_rooms_guest_token" ON "collab_rooms" ("guest_link_token"); +CREATE INDEX IF NOT EXISTS "idx_collab_rooms_owner" ON "collab_rooms" ("owner_user_id"); +ALTER TABLE "collab_rooms" RENAME TO "p_session_sharing_rooms"; +CREATE TABLE IF NOT EXISTS "collab_room_members" ( + "id" serial PRIMARY KEY, + "room_id" varchar(255) NOT NULL, + "user_id" varchar(255) NOT NULL, + "room_role" text NOT NULL DEFAULT 'member', + "added_by" varchar(255), + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("room_id") REFERENCES "p_session_sharing_rooms" ("id") ON DELETE CASCADE, + FOREIGN KEY ("added_by") REFERENCES "users" ("id") ON DELETE SET NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_collab_room_members_room_user" ON "collab_room_members" ("room_id", "user_id"); +CREATE INDEX IF NOT EXISTS "idx_collab_room_members_user" ON "collab_room_members" ("user_id"); +ALTER TABLE "collab_room_members" RENAME TO "p_session_sharing_room_members"; + +-- ==== plugin sso 0001_adopt_sso_providers.sql ==== +-- sso 0001: adopt_sso_providers +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "sso_providers" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL, + "type" text NOT NULL, + "enabled" boolean NOT NULL DEFAULT true, + "display_order" integer NOT NULL DEFAULT 0, + "config" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE "sso_providers" RENAME TO "p_sso_providers"; + +-- ==== plugin sso 0002_legacy_callback.sql ==== +-- sso 0002: legacy_callback +-- Generated by termix-plugin migrations. Review before committing. + +ALTER TABLE "p_sso_providers" ADD COLUMN "legacy_callback" boolean NOT NULL DEFAULT false; +-- Providers that existed before 2.9 were registered with the identity +-- provider under /users/oidc/callback, so they keep sending that URI. +UPDATE "p_sso_providers" SET "legacy_callback" = true; + +-- ==== plugin step-ca 0001_create_certs.sql ==== +-- step-ca 0001: create_certs +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "p_step_ca_certs" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "email" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" varchar(255) NOT NULL, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_step_ca_certs_user_host" ON "p_step_ca_certs" ("user_id", "host_id"); + +-- ==== plugin termix-identity 0001_adopt_termix_identity_tables.sql ==== +-- termix-identity 0001: adopt_termix_identity_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare +-- (keys and ca to their identity, keys to ssh_credentials). + +CREATE TABLE IF NOT EXISTS "termix_identities" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL UNIQUE, + "handle" varchar(255) NOT NULL UNIQUE, + "description" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "termix_identities" RENAME TO "p_termix_identity_identities"; +CREATE TABLE IF NOT EXISTS "termix_identity_keys" ( + "id" serial PRIMARY KEY, + "identity_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "public_key" text NOT NULL, + "key_type" text NOT NULL, + "algorithm" text NOT NULL, + "label" text, + "comment" text, + "source" text NOT NULL DEFAULT 'manual', + "credential_id" integer, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("identity_id") REFERENCES "p_termix_identity_identities" ("id") ON DELETE CASCADE, + FOREIGN KEY ("credential_id") REFERENCES "ssh_credentials" ("id") ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS "idx_termix_identity_keys_identity" ON "termix_identity_keys" ("identity_id"); +ALTER TABLE "termix_identity_keys" RENAME TO "p_termix_identity_keys"; +CREATE TABLE IF NOT EXISTS "termix_identity_ca" ( + "id" serial PRIMARY KEY, + "identity_id" integer NOT NULL UNIQUE, + "user_id" varchar(255) NOT NULL, + "public_key" text NOT NULL, + "private_key" text NOT NULL, + "validity_days" integer NOT NULL DEFAULT 90, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("identity_id") REFERENCES "p_termix_identity_identities" ("id") ON DELETE CASCADE +); +ALTER TABLE "termix_identity_ca" RENAME TO "p_termix_identity_ca"; + +-- ==== plugin tmux-monitor 0001_adopt_tmux_session_tags.sql ==== +-- tmux-monitor 0001: adopt_tmux_session_tags +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "tmux_session_tags" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "session_name" text NOT NULL, + "tag" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +ALTER TABLE "tmux_session_tags" RENAME TO "p_tmux_monitor_session_tags"; + +-- ==== plugin totp 0001_create_enrollments.sql ==== +-- totp 0001: create_enrollments +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "p_totp_enrollments" ( + "user_id" varchar(255) PRIMARY KEY NOT NULL, + "secret" text, + "pending_secret" text, + "backup_codes" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); + +-- ==== plugin vault 0001_adopt_vault_tables.sql ==== +-- vault 0001: adopt_vault_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "vault_profiles" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "description" text, + "folder" text, + "tags" text, + "vault_addr" text NOT NULL, + "vault_namespace" text, + "oidc_mount" text, + "oidc_role" text, + "ssh_mount" text, + "ssh_role" text NOT NULL, + "valid_principals" text, + "key_type" text, + "shared" boolean NOT NULL DEFAULT false, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "vault_profiles" RENAME TO "p_vault_profiles"; +CREATE TABLE IF NOT EXISTS "vault_tokens" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "profile_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" varchar(255) NOT NULL, + "last_used" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_vault_tokens_user_profile" ON "vault_tokens" ("user_id", "profile_id"); +ALTER TABLE "vault_tokens" RENAME TO "p_vault_tokens"; + +-- ==== plugin webauthn 0001_adopt_webauthn_credentials.sql ==== +-- webauthn 0001: adopt_webauthn_credentials +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "webauthn_credentials" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "credential_id" varchar(255) NOT NULL UNIQUE, + "public_key" text NOT NULL, + "counter" integer NOT NULL DEFAULT 0, + "device_type" text, + "backed_up" boolean NOT NULL DEFAULT false, + "transports" text, + "user_verification" text NOT NULL DEFAULT 'preferred', + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +ALTER TABLE "webauthn_credentials" RENAME TO "p_webauthn_credentials"; diff --git a/src/backend/tests/plugins/built-harness.ts b/src/backend/tests/plugins/built-harness.ts index c9aecbd05..62784a721 100644 --- a/src/backend/tests/plugins/built-harness.ts +++ b/src/backend/tests/plugins/built-harness.ts @@ -10,10 +10,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import Database from "better-sqlite3"; +import { DATA_DIR_TOKEN } from "../fixtures/upgrade/rows.js"; +import { remoteTestDialect, seedRemote28 } from "./remote-seed.js"; export const REPO_ROOT = path.resolve(__dirname, "../../../.."); export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins"); const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql"); +/** The committed 2.8 install: db.sqlite, the fixture.env it was keyed with and its files. */ +export const UPGRADE_FIXTURE_DIR = path.join(__dirname, "../fixtures/upgrade"); /** Every plugin in the repo, by folder. */ export function sourcePluginIds(): string[] { @@ -87,23 +91,90 @@ export function create28Database() { export interface BootedCore { dataDir: string; - /** The live database core and the plugins write to. */ - sqlite: Database.Database; - shutdown: () => Promise; + /** The live database core and the plugins write to; null on Postgres and MySQL. */ + sqlite: Database.Database | null; + /** The 2.8 database file exactly as it was written to DATA_DIR, if one was. */ + seed: Buffer | null; + /** keepDataDir writes the database to disk and leaves DATA_DIR for another boot. */ + shutdown: (options?: { keepDataDir?: boolean }) => Promise; } +/** A 2.8 data directory to boot: a seeded database, or a folder holding one. */ +export type BootSource = + Database.Database | { fixtureDir: string } | { reuseDataDir: string }; + +/** + * Lays a committed 2.8 install (fixtures/upgrade) into DATA_DIR: its key file + * and the files its rows point at, with the fixture's DATA_DIR token in those + * rows swapped for the real path, as 2.8 stored them absolute. + */ +function placeFixture(fixtureDir: string, dataDir: string): Buffer { + fs.copyFileSync( + path.join(fixtureDir, "fixture.env"), + path.join(dataDir, ".env"), + ); + fs.cpSync(path.join(fixtureDir, "files"), dataDir, { recursive: true }); + + const sqlite = new Database( + fs.readFileSync(path.join(fixtureDir, "db.sqlite")), + ); + try { + sqlite + .prepare( + "UPDATE session_recordings SET recording_path = replace(recording_path, ?, ?)", + ) + .run(DATA_DIR_TOKEN, dataDir.split(path.sep).join("/")); + return sqlite.serialize(); + } finally { + sqlite.close(); + } +} + +const SYSTEM_SECRETS = [ + "JWT_SECRET", + "DATABASE_KEY", + "ENCRYPTION_KEY", + "INTERNAL_AUTH_TOKEN", +]; + /** * Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot - * sequence up to, not including, opening the HTTP server. Call - * vi.resetModules() before, so module state from other tests is gone. + * sequence up to, not including, opening the HTTP server: the pre-upgrade + * backup, the database, core's migrations, then the plugins and their data + * moves. Call vi.resetModules() before, so module state from other tests is + * gone. */ -export async function bootCore(seed: Database.Database): Promise { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-")); +export async function bootCore(source: BootSource): Promise { + const dataDir = + "reuseDataDir" in source + ? source.reuseDataDir + : fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-")); process.env.DATA_DIR = dataDir; process.env.DB_FILE_ENCRYPTION = "false"; process.env.ALLOW_EMPTY_DATA_DIR = "true"; process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR; - fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + // SystemCrypto prefers these over DATA_DIR/.env, and a previous boot in + // this worker set them. + for (const key of SYSTEM_SECRETS) delete process.env[key]; + + let seed: Buffer | null = null; + if (source instanceof Database) { + seed = source.serialize(); + } else if ("fixtureDir" in source) { + seed = placeFixture(source.fixtureDir, dataDir); + } + + // TEST_DIALECT=postgres|mysql puts the same 2.8 data on a real server. + const remote = remoteTestDialect(); + if (remote) { + const url = process.env.TEST_DATABASE_URL; + if (!url) throw new Error(`TEST_DIALECT=${remote} needs TEST_DATABASE_URL`); + process.env.DATABASE_DIALECT = remote; + process.env.DATABASE_URL = url; + if (seed) await seedRemote28(remote, url, seed); + } else if (seed) { + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed); + } const { SystemCrypto } = await import("../../utils/system-crypto.js"); const systemCrypto = SystemCrypto.getInstance(); @@ -112,10 +183,13 @@ export async function bootCore(seed: Database.Database): Promise { await systemCrypto.initializeEncryptionKey(); await systemCrypto.initializeInternalAuthToken(); + const { backupBeforeUpgrade, runCoreBootMigrations } = + await import("../../boot.js"); + await backupBeforeUpgrade({ dataDir, version: "2.9.0-test" }); + const db = await import("../../database/db/index.js"); await db.initializeDatabase(); - const { runCoreBootMigrations } = await import("../../boot.js"); await runCoreBootMigrations(); const { primeKnownPermissions } = @@ -132,9 +206,19 @@ export async function bootCore(seed: Database.Database): Promise { return { dataDir, - sqlite: db.getSqlite(), - shutdown: async () => { + sqlite: remote ? null : db.getSqlite(), + seed, + shutdown: async ({ keepDataDir = false } = {}) => { await shutdownPlugins(); + if (keepDataDir) { + if (!remote) { + fs.writeFileSync( + path.join(dataDir, "db.sqlite"), + db.getSqlite().serialize(), + ); + } + return; + } fs.rmSync(dataDir, { recursive: true, force: true }); }, }; diff --git a/src/backend/tests/plugins/built-plugins-boot.test.ts b/src/backend/tests/plugins/built-plugins-boot.test.ts index 05bd04414..3dd11bc68 100644 --- a/src/backend/tests/plugins/built-plugins-boot.test.ts +++ b/src/backend/tests/plugins/built-plugins-boot.test.ts @@ -129,8 +129,8 @@ describe("the built plugins on a 2.8 database", () => { }); it("records every plugin migration it ran", async () => { - const recorded = core.sqlite - .prepare( + const recorded = core + .sqlite!.prepare( "SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id", ) .all() as Array<{ plugin_id: string; n: number }>; diff --git a/src/backend/tests/plugins/remote-seed.ts b/src/backend/tests/plugins/remote-seed.ts new file mode 100644 index 000000000..4de246962 --- /dev/null +++ b/src/backend/tests/plugins/remote-seed.ts @@ -0,0 +1,146 @@ +/** + * Puts a 2.8 install on a real Postgres or MySQL server for the upgrade test: + * wipes the database, applies release-2.8.0-tag's own drizzle migrations + * (UPGRADE_LEGACY_MIGRATIONS_DIR, which scripts/upgrade-check.sh extracts + * from git), then copies every row of the SQLite fixture across. After that + * the database is exactly what a 2.8 server left behind, and the normal boot + * takes it the rest of the way. + */ + +import Database from "better-sqlite3"; +import { sql, type SQL } from "drizzle-orm"; +import { ROWS } from "../fixtures/upgrade/rows.js"; + +export type RemoteDialect = "postgres" | "mysql"; + +type Remote = { execute: (query: SQL) => Promise }; + +/** TEST_DIALECT, the same switch the repository tests use. */ +export function remoteTestDialect( + env: NodeJS.ProcessEnv = process.env, +): RemoteDialect | null { + const value = env.TEST_DIALECT?.trim().toLowerCase(); + return value === "postgres" || value === "mysql" ? value : null; +} + +async function rows(db: Remote, query: SQL): Promise { + const result = (await db.execute(query)) as { rows?: T[] } | T[]; + if (Array.isArray(result)) { + return (Array.isArray(result[0]) ? result[0] : result) as T[]; + } + return result.rows ?? []; +} + +async function wipe(db: Remote, dialect: RemoteDialect): Promise { + if (dialect === "postgres") { + await db.execute(sql.raw("DROP SCHEMA IF EXISTS drizzle CASCADE")); + await db.execute(sql.raw("DROP SCHEMA public CASCADE")); + await db.execute(sql.raw("CREATE SCHEMA public")); + return; + } + const [{ name }] = await rows<{ name: string }>( + db, + sql.raw("SELECT DATABASE() AS name"), + ); + await db.execute(sql.raw(`DROP DATABASE \`${name}\``)); + await db.execute(sql.raw(`CREATE DATABASE \`${name}\``)); + await db.execute(sql.raw(`USE \`${name}\``)); +} + +async function columnTypes( + db: Remote, + dialect: RemoteDialect, + table: string, +): Promise> { + const schema = + dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`; + const found = await rows<{ name: string; type: string }>( + db, + sql`SELECT column_name AS name, data_type AS type FROM information_schema.columns WHERE table_schema = ${schema} AND table_name = ${table}`, + ); + return new Map(found.map((column) => [column.name, column.type])); +} + +async function resyncSequence(db: Remote, table: string): Promise { + const [sequence] = await rows<{ name: string | null }>( + db, + sql.raw( + `SELECT pg_get_serial_sequence('"${table}"', 'id') AS name FROM information_schema.columns ` + + `WHERE table_schema = current_schema() AND table_name = '${table}' AND column_name = 'id'`, + ), + ); + if (!sequence?.name) return; + await db.execute( + sql.raw( + `SELECT setval('${sequence.name}', COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`, + ), + ); +} + +export async function seedRemote28( + dialect: RemoteDialect, + url: string, + fixture: Buffer, +): Promise { + const legacyMigrations = process.env.UPGRADE_LEGACY_MIGRATIONS_DIR; + if (!legacyMigrations) { + throw new Error( + "UPGRADE_LEGACY_MIGRATIONS_DIR must point at release-2.8.0-tag's drizzle folder. Run scripts/upgrade-check.sh.", + ); + } + + const { drizzle } = + dialect === "postgres" + ? await import("drizzle-orm/node-postgres") + : await import("drizzle-orm/mysql2"); + const db = ( + drizzle as unknown as ( + config: unknown, + ) => Remote & { $client: { end: () => Promise } } + )( + dialect === "postgres" + ? { connection: url } + : { connection: { uri: url, connectionLimit: 1 } }, + ); + const source = new Database(fixture, { readonly: true }); + + try { + await wipe(db, dialect); + + const { runRemoteMigrations } = + await import("../../database/db/migrate.js"); + await runRemoteMigrations(dialect, db as never, { + DRIZZLE_MIGRATIONS_DIR: legacyMigrations, + }); + + for (const table of Object.keys(ROWS)) { + const types = await columnTypes(db, dialect, table); + if (types.size === 0) { + throw new Error(`2.8's ${dialect} schema has no ${table} table`); + } + for (const row of source + .prepare(`SELECT * FROM "${table}"`) + .all() as Array>) { + const columns = Object.keys(row).filter( + (column) => types.has(column) && row[column] !== null, + ); + const values = columns.map((column) => + types.get(column) === "boolean" ? Boolean(row[column]) : row[column], + ); + await db.execute( + sql`INSERT INTO ${sql.identifier(table)} (${sql.join( + columns.map((column) => sql.identifier(column)), + sql`, `, + )}) VALUES (${sql.join( + values.map((value) => sql`${value}`), + sql`, `, + )})`, + ); + } + if (dialect === "postgres") await resyncSequence(db, table); + } + } finally { + source.close(); + await db.$client.end(); + } +} diff --git a/src/backend/tests/plugins/upgrade-2-8.test.ts b/src/backend/tests/plugins/upgrade-2-8.test.ts deleted file mode 100644 index 670dee861..000000000 --- a/src/backend/tests/plugins/upgrade-2-8.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * The quick upgrade check: a 2.8 database with rows in the tables plugins - * adopt, host columns plugins took over and settings keys they moved boots - * into 2.9.0, and the data reads back through the plugins' own APIs. - */ - -import express from "express"; -import request from "supertest"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { - assertPluginsBuilt, - bootCore, - create28Database, - type BootedCore, -} from "./built-harness.js"; - -const BOOT_TIMEOUT = 180_000; -const USER = "user-upgrade"; - -let core: BootedCore; -let app: express.Express; -let token: string; -const ids: Record = {}; - -function seed() { - const { sqlite, insert } = create28Database(); - insert("users", { - id: USER, - username: "admin", - password_hash: "not-a-real-hash", - is_admin: 1, - }); - - // A host that changed every plugin-owned switch away from its default... - ids.custom = insert("ssh_data", { - user_id: USER, - name: "custom", - ip: "10.0.0.1", - port: 22, - username: "root", - auth_type: "password", - enable_terminal: 1, - enable_command_history: 0, - enable_terminal_toolbar: 0, - enable_session_logging: 0, - allow_session_sharing: 0, - enable_file_manager: 0, - default_path: "/srv/data", - scp_legacy: 1, - enable_tunnel: 1, - tunnel_connections: [ - { - sourcePort: 8080, - endpointPort: 80, - endpointHost: "custom", - autoStart: false, - }, - ], - enable_web_ui: 1, - web_ui_config: { - endpoints: [ - { id: "grafana", label: "Grafana", port: 3000, protocol: "http" }, - ], - }, - enable_tmux_monitor: 1, - enable_docker: 1, - }); - // ...and one that never touched them, which must keep 2.8's defaults. - ids.plain = insert("ssh_data", { - user_id: USER, - name: "plain", - ip: "10.0.0.2", - port: 22, - username: "root", - auth_type: "password", - }); - - insert("snippets", { - user_id: USER, - name: "upgrade-snippet", - content: "uptime", - }); - insert("fleets", { user_id: USER, name: "upgrade-fleet" }); - insert("user_workspaces", { - user_id: USER, - name: "upgrade-workspace", - payload: {}, - }); - insert("network_topology", { - user_id: USER, - topology: { nodes: [{ id: "upgrade-node" }], edges: [] }, - }); - insert("automations", { - user_id: USER, - name: "upgrade-automation", - enabled: 0, - definition: { - version: 1, - trigger: { type: "manual" }, - steps: [], - }, - }); - insert("homepage_items", { - user_id: USER, - type_id: "clock", - title: "upgrade-widget", - config: {}, - }); - insert("vault_profiles", { - user_id: USER, - name: "upgrade-vault", - vault_addr: "https://vault.example.com", - ssh_role: "admin", - }); - insert("termix_identities", { user_id: USER, handle: "upgrade-handle" }); - insert("sso_providers", { - name: "upgrade-sso", - type: "oidc", - config: { - issuerUrl: "https://id.example.com", - clientId: "termix", - clientSecret: "secret", - }, - }); - - insert("settings", { key: "terminal_session_timeout_minutes", value: "45" }); - insert("settings", { key: "step_ca_url", value: "https://ca.example.com" }); - return sqlite; -} - -beforeAll(async () => { - assertPluginsBuilt(); - vi.resetModules(); - core = await bootCore(seed()); - - const { AuthManager } = await import("../../utils/auth-manager.js"); - const auth = AuthManager.getInstance(); - await auth.registerUser(USER); - await auth.unlockWithSystemKey(USER); - token = await auth.generateJWTToken(USER, { - deviceType: "web", - deviceInfo: "upgrade test", - }); - - const { mountPluginApi } = - await import("../../database/routes/plugin-api-routes.js"); - const { default: pluginRoutes } = - await import("../../database/routes/plugins.js"); - app = express(); - app.use(express.json()); - app.use("/plugins", pluginRoutes); - mountPluginApi(app); -}, BOOT_TIMEOUT); - -afterAll(async () => { - await core?.shutdown(); -}, BOOT_TIMEOUT); - -function get(path: string) { - return request(app).get(path).set("Authorization", `Bearer ${token}`); -} - -async function hostSettings(pluginId: string, hostId: number) { - const response = await get(`/plugins/${pluginId}/settings/host/${hostId}`); - expect(response.status, `${pluginId} host settings`).toBe(200); - return response.body.values as Record; -} - -describe("upgrading a 2.8 database", () => { - it("renames every adopted table the 2.8 schema had", () => { - const tables = new Set( - ( - core.sqlite - .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") - .all() as Array<{ name: string }> - ).map((row) => row.name), - ); - for (const legacy of [ - "snippets", - "fleets", - "user_workspaces", - "network_topology", - "automations", - "homepage_items", - "vault_profiles", - "termix_identities", - "sso_providers", - ]) { - expect(tables.has(legacy), `${legacy} was adopted`).toBe(false); - } - }); - - it("reads the adopted rows back through each plugin's routes", async () => { - const checks: Array<[string, string]> = [ - ["/plugin-api/snippets/", "upgrade-snippet"], - ["/plugin-api/fleets/", "upgrade-fleet"], - ["/plugin-api/workspaces/", "upgrade-workspace"], - ["/plugin-api/network-topology/", "upgrade-node"], - ["/plugin-api/automations/", "upgrade-automation"], - ["/plugin-api/homepage/items", "upgrade-widget"], - ["/plugin-api/vault/profiles", "upgrade-vault"], - ["/plugin-api/termix-identity/me", "upgrade-handle"], - ["/plugin-api/sso/providers", "upgrade-sso"], - ]; - for (const [path, marker] of checks) { - const response = await get(path); - expect(response.status, path).toBe(200); - expect(JSON.stringify(response.body), path).toContain(marker); - } - }); - - it("moves every changed host switch into its plugin's host settings", async () => { - expect(await hostSettings("ssh-terminal", ids.custom)).toMatchObject({ - enableTerminal: true, - enableCommandHistory: false, - enableTerminalToolbar: false, - }); - expect(await hostSettings("session-recording", ids.custom)).toMatchObject({ - enableSessionRecording: false, - }); - expect(await hostSettings("session-sharing", ids.custom)).toMatchObject({ - allowSessionSharing: false, - }); - expect(await hostSettings("file-manager", ids.custom)).toMatchObject({ - enableFileManager: false, - defaultPath: "/srv/data", - scpLegacy: true, - }); - const tunnels = await hostSettings("tunnels", ids.custom); - expect(tunnels.enableTunnel).toBe(true); - expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080"); - const web = await hostSettings("web-endpoint", ids.custom); - expect(web.enableWebUi).toBe(true); - expect(JSON.stringify(web.webUiConfig)).toContain("Grafana"); - expect(await hostSettings("tmux-monitor", ids.custom)).toMatchObject({ - enableTmuxMonitor: true, - }); - expect(await hostSettings("docker", ids.custom)).toMatchObject({ - enableDocker: true, - }); - }); - - it("keeps 2.8's defaults for a host that never changed them", async () => { - expect(await hostSettings("file-manager", ids.plain)).toMatchObject({ - enableFileManager: true, - }); - expect(await hostSettings("ssh-terminal", ids.plain)).toMatchObject({ - enableTerminal: true, - enableCommandHistory: true, - }); - expect(await hostSettings("session-recording", ids.plain)).toMatchObject({ - enableSessionRecording: true, - }); - expect(await hostSettings("session-sharing", ids.plain)).toMatchObject({ - allowSessionSharing: true, - }); - }); - - it("moves core settings keys into plugin settings", async () => { - const terminal = await get("/plugins/ssh-terminal/settings/admin"); - expect(terminal.status).toBe(200); - expect(terminal.body.values.sessionTimeoutMinutes).toBe(45); - - const stepCa = await get("/plugins/step-ca/settings/admin"); - expect(stepCa.status).toBe(200); - expect(stepCa.body.values.caUrl).toBe("https://ca.example.com"); - }); -}); diff --git a/src/backend/tests/plugins/upgrade-fixture-data.test.ts b/src/backend/tests/plugins/upgrade-fixture-data.test.ts new file mode 100644 index 000000000..ecf4b30a7 --- /dev/null +++ b/src/backend/tests/plugins/upgrade-fixture-data.test.ts @@ -0,0 +1,72 @@ +/** + * The committed 2.8 fixture still matches rows.ts and still fills every + * table a plugin adopts. A new legacy table in LEGACY_TABLE_OWNERS fails here + * until it gets fixture rows (npx tsx scripts/build-upgrade-fixture.ts). + */ + +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { afterAll, describe, expect, it } from "vitest"; +import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db"; +import { FieldCrypto } from "../../utils/field-crypto.js"; +import { UPGRADE_FIXTURE_DIR } from "./built-harness.js"; +import { + ENCRYPTED_COLUMNS, + RECORDING_FILES, + ROWS, +} from "../fixtures/upgrade/rows.js"; + +const sqlite = new Database(path.join(UPGRADE_FIXTURE_DIR, "db.sqlite"), { + readonly: true, +}); + +afterAll(() => sqlite.close()); + +const count = (table: string) => + ( + sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as { + n: number; + } + ).n; + +describe("the committed 2.8 upgrade fixture", () => { + it("fills every table a plugin adopts", () => { + for (const table of Object.keys(LEGACY_TABLE_OWNERS)) { + expect(ROWS[table]?.length ?? 0, table).toBeGreaterThan(0); + expect(count(table), table).toBeGreaterThan(0); + } + }); + + it("holds exactly the rows in rows.ts", () => { + for (const [table, rows] of Object.entries(ROWS)) { + // Each user also has a wrapped data key in settings. + const extra = table === "settings" ? ROWS.users.length : 0; + expect(count(table), table).toBe(rows.length + extra); + } + }); + + it("stores the columns 2.8 encrypted as ciphertext", () => { + for (const [table, column] of ENCRYPTED_COLUMNS) { + const values = ( + sqlite + .prepare( + `SELECT "${column}" AS value FROM "${table}" WHERE "${column}" IS NOT NULL AND "${column}" <> ''`, + ) + .all() as Array<{ value: string }> + ).map((row) => row.value); + for (const value of values) { + expect(FieldCrypto.isEncrypted(value), `${table}.${column}`).toBe(true); + } + } + }); + + it("ships the recording files its rows point at", () => { + for (const relative of Object.values(RECORDING_FILES)) { + expect( + fs.existsSync(path.join(UPGRADE_FIXTURE_DIR, "files", relative)), + relative, + ).toBe(true); + } + }); +}); diff --git a/src/backend/tests/plugins/upgrade-fixture.test.ts b/src/backend/tests/plugins/upgrade-fixture.test.ts new file mode 100644 index 000000000..180fb6ae1 --- /dev/null +++ b/src/backend/tests/plugins/upgrade-fixture.test.ts @@ -0,0 +1,649 @@ +/** + * The 2.8 to 2.9.0 upgrade, end to end. The committed 2.8 install in + * fixtures/upgrade boots through the pre-upgrade backup, core's migrations, + * every built plugin's migrations and the data moves, and everything it held + * reads back through the plugins' routes, their settings and their ctx. Then + * the same data directory boots again and nothing changes. + * + * TEST_DIALECT=postgres|mysql with TEST_DATABASE_URL runs it against a real + * server instead (scripts/upgrade-check.sh sets that up). + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db"; +import { + assertPluginsBuilt, + bootCore, + BUILT_PLUGINS_DIR, + UPGRADE_FIXTURE_DIR, + type BootedCore, +} from "./built-harness.js"; +import { remoteTestDialect } from "./remote-seed.js"; +import { + FIXTURE_PASSWORD, + HOST_AUTH_TYPES, + HOSTS, + LDAP_PROVIDER_ID, + OPERATORS_PERMISSIONS, + ROWS, + SSO_PROVIDER_ID, + SYNCED_ENTITY_TYPES_28, + TOTP_BACKUP_CODES, + TOTP_SECRET, + USER_ROLE_PERMISSIONS_28, + USERS, +} from "../fixtures/upgrade/rows.js"; + +const BOOT_TIMEOUT = 240_000; + +type UserKey = keyof typeof USERS; + +interface Booted { + core: BootedCore; + app: express.Express; + tokens: Record; + errors: string[]; +} + +/** legacy table -> the p_ table its plugin renamed it to. */ +function adoptedNames(): Map { + const names = new Map(); + for (const id of new Set(Object.values(LEGACY_TABLE_OWNERS))) { + const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite"); + for (const file of fs.readdirSync(dir)) { + const text = fs.readFileSync(path.join(dir, file), "utf8"); + for (const match of text.matchAll( + /ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)"/g, + )) { + names.set(match[1], match[2]); + } + } + } + return names; +} + +async function boot(dataDir?: string): Promise { + vi.resetModules(); + const errors: string[] = []; + const error = vi.spyOn(console, "error").mockImplementation((...args) => { + errors.push(args.map(String).join(" ")); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let core: BootedCore; + try { + core = await bootCore( + dataDir ? { reuseDataDir: dataDir } : { fixtureDir: UPGRADE_FIXTURE_DIR }, + ); + } finally { + error.mockRestore(); + warn.mockRestore(); + } + + const { AuthManager } = await import("../../utils/auth-manager.js"); + const auth = AuthManager.getInstance(); + const tokens = {} as Record; + for (const [key, userId] of Object.entries(USERS)) { + await auth.unlockWithSystemKey(userId); + tokens[key as UserKey] = await auth.generateJWTToken(userId, { + deviceType: "web", + deviceInfo: "upgrade test", + }); + } + + const { mountPluginApi } = + await import("../../database/routes/plugin-api-routes.js"); + const { default: pluginRoutes } = + await import("../../database/routes/plugins.js"); + const { default: userRoutes } = + await import("../../database/routes/users.js"); + const app = express(); + app.use(express.json()); + app.use("/users", userRoutes); + app.use("/plugins", pluginRoutes); + mountPluginApi(app); + + return { core, app, tokens, errors }; +} + +function get(booted: Booted, url: string, as: UserKey = "admin") { + return request(booted.app) + .get(url) + .set("Authorization", `Bearer ${booted.tokens[as]}`); +} + +async function settings( + booted: Booted, + pluginId: string, + scope: string, + as: UserKey = "admin", +): Promise> { + const response = await get( + booted, + `/plugins/${pluginId}/settings/${scope}`, + as, + ); + expect(response.status, `${pluginId} ${scope} settings`).toBe(200); + return response.body.values as Record; +} + +/** A ctx for a running plugin, the one its own code gets. */ +async function pluginCtx(pluginId: string) { + const { getPluginRuntime } = await import("../../plugins/index.js"); + const { createPluginContext } = await import("../../plugins/ctx.js"); + const loaded = getPluginRuntime().loader.get(pluginId); + if (!loaded?.handle) throw new Error(`${pluginId} is not running`); + return createPluginContext(loaded.manifest, loaded.handle); +} + +async function asUser(userId: string, fn: () => Promise): Promise { + const { runAsActor } = await import("../../plugins/actor.js"); + return runAsActor(userId, "asUser", fn); +} + +/** Every row of a plugin table, read through that plugin's ctx.db. */ +async function pluginRows( + pluginId: string, + table: string, +): Promise>> { + const ctx = await pluginCtx(pluginId); + /* eslint-disable @typescript-eslint/no-explicit-any */ + const client = await ctx.db.client(); + const query = sql`SELECT * FROM ${sql.identifier(table)}`; + if (typeof client.all === "function") return client.all(query); + const result = await client.execute(query); + return Array.isArray(result) ? result[0] : result.rows; + /* eslint-enable @typescript-eslint/no-explicit-any */ +} + +/** Bookkeeping counts, compared between the first and second boot. */ +async function counts(): Promise> { + const { selectRows } = + await import("../../utils/crypto-migration/raw-rows.js"); + const out: Record = {}; + const tables = [ + "plugin_migrations", + "plugin_settings", + "user_second_factors", + "user_external_identities", + "roles", + "user_roles", + "ssh_data", + ...adoptedNames().values(), + ]; + for (const table of tables) { + const [row] = await selectRows<{ n: number | string }>( + sql`SELECT COUNT(*) AS n FROM ${sql.identifier(table)}`, + ); + out[table] = Number(row.n); + } + return out; +} + +function totpCode(secret: string, at = Date.now()): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = ""; + for (const char of secret.replace(/=+$/, "")) { + bits += alphabet.indexOf(char).toString(2).padStart(5, "0"); + } + const key = Buffer.from( + bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)), + ); + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000))); + const hmac = crypto.createHmac("sha1", key).update(counter).digest(); + const offset = hmac[hmac.length - 1] & 0xf; + const value = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000; + return String(value).padStart(6, "0"); +} + +/** Everything a 2.8 install had, checked against a booted 2.9.0. */ +function upgradeChecks(current: () => Booted, bootIndex: number) { + it("boots without logging an error", () => { + expect(current().errors).toEqual([]); + }); + + it("activates every plugin", async () => { + const { getPluginRuntime } = await import("../../plugins/index.js"); + const notActive = getPluginRuntime() + .loader.list() + .filter((plugin) => plugin.state !== "active") + .map((plugin) => `${plugin.id}: ${plugin.state} ${plugin.lastError}`); + expect(notActive).toEqual([]); + }); + + it("keeps every row of every adopted table, readable by its plugin", async () => { + const names = adoptedNames(); + for (const [legacy, pluginId] of Object.entries(LEGACY_TABLE_OWNERS)) { + const seeded = ROWS[legacy] ?? []; + expect(seeded.length, `the fixture fills ${legacy}`).toBeGreaterThan(0); + const table = names.get(legacy); + expect(table, `${pluginId} adopts ${legacy}`).toBeDefined(); + // 2.8 kept LDAP directories in sso_providers; the ldap plugin takes them. + const expected = + legacy === "sso_providers" + ? seeded.filter((row) => row.type !== "ldap") + : seeded; + const rows = await pluginRows(pluginId, table!); + const ids = rows.map((row) => String(row.id)).sort(); + expect(ids, `${legacy} -> ${table}`).toEqual( + expected.map((row) => String(row.id)).sort(), + ); + } + const ldap = await pluginRows("ldap", "p_ldap_providers"); + expect(ldap.map((row) => String(row.id))).toEqual([ + String(LDAP_PROVIDER_ID), + ]); + }); + + it("reads the adopted rows back through each plugin's routes", async () => { + const checks: Array<[string, string, UserKey?]> = [ + ["/plugin-api/snippets/", "d4-snippet"], + ["/plugin-api/snippets/shared", "d4-shared-snippet", "alice"], + ["/plugin-api/fleets/", "d4-fleet"], + ["/plugin-api/workspaces/", "d4-workspace"], + ["/plugin-api/network-topology/", "d4-node"], + ["/plugin-api/automations/", "d4-automation"], + ["/plugin-api/homepage/items", "d4-widget"], + ["/plugin-api/homepage/service-links", "d4-link"], + ["/plugin-api/vault/profiles", "d4-vault"], + ["/plugin-api/termix-identity/me", "d4-handle"], + ["/plugin-api/sso/providers", "d4-sso"], + ["/plugin-api/ldap/providers", "d4-ldap"], + [ + `/plugin-api/ssh-terminal/command-history/${HOSTS.password}`, + "d4-history-command", + ], + ["/plugin-api/tunnels/presets", "d4-preset"], + ["/plugin-api/ai/providers", "d4-provider"], + ["/plugin-api/ai/conversations/1", "d4-message"], + ["/plugin-api/secret-sources", "d4-source"], + ["/plugin-api/session-sharing/rooms", "d4-room"], + ["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"], + ]; + const failures: string[] = []; + for (const [url, marker, as] of checks) { + const response = await get(current(), url, as); + if ( + response.status !== 200 || + !JSON.stringify(response.body).includes(marker) + ) { + failures.push( + `${url} -> ${response.status} ${JSON.stringify(response.body).slice(0, 200)}`, + ); + } + } + expect(failures).toEqual([]); + }); + + it("moves every host flag into its plugin's host settings", async () => { + const booted = current(); + const host = (id: string) => settings(booted, id, `host/${HOSTS.password}`); + expect(await host("ssh-terminal")).toMatchObject({ + enableTerminal: true, + enableCommandHistory: false, + enableTerminalToolbar: false, + }); + expect(await host("session-recording")).toMatchObject({ + enableSessionRecording: false, + }); + expect(await host("session-sharing")).toMatchObject({ + allowSessionSharing: false, + }); + expect(await host("file-manager")).toMatchObject({ + enableFileManager: false, + defaultPath: "/srv/d4", + scpLegacy: true, + }); + const tunnels = await host("tunnels"); + expect(tunnels.enableTunnel).toBe(true); + expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080"); + const web = await host("web-endpoint"); + expect(web.enableWebUi).toBe(true); + expect(JSON.stringify(web.webUiConfig)).toContain("d4-grafana"); + expect(await host("tmux-monitor")).toMatchObject({ + enableTmuxMonitor: true, + }); + expect(await host("docker")).toMatchObject({ + enableDocker: true, + containerRuntime: "podman", + }); + expect(await host("ai")).toMatchObject({ enableAiAssistant: true }); + expect(await host("wake-on-lan")).toMatchObject({ + macAddress: "AA:BB:CC:DD:EE:04", + broadcastAddress: "10.4.0.255", + }); + expect(await host("host-metrics")).toMatchObject({ + metricsEnabled: false, + metricsInterval: 45, + enabledWidgets: ["cpu", "memory"], + }); + + const proxmox = await settings(booted, "proxmox", `host/${HOSTS.key}`); + expect(proxmox.enableProxmox).toBe(true); + expect(JSON.stringify(proxmox)).toContain("pve.d4.example"); + + expect( + await settings(booted, "remote-desktop", `host/${HOSTS.desktop}`), + ).toMatchObject({ + enableRdp: true, + enableVnc: true, + enableTelnet: true, + rdpPort: 3390, + vncPort: 5901, + telnetPort: 2323, + }); + expect( + await settings(booted, "remote-desktop", `host/${HOSTS.rdpOnly}`), + ).toMatchObject({ enableRdp: true }); + + expect( + await settings(booted, "vault", `host/${HOSTS.vault}`), + ).toMatchObject({ profileId: 1 }); + expect( + await settings(booted, "warpgate", `host/${HOSTS.warpgate}`), + ).toMatchObject({ useWarpgate: true }); + }); + + it("keeps 2.8's defaults on a host that never changed them", async () => { + const booted = current(); + const host = (id: string) => settings(booted, id, `host/${HOSTS.plain}`); + expect(await host("file-manager")).toMatchObject({ + enableFileManager: true, + }); + expect(await host("ssh-terminal")).toMatchObject({ + enableTerminal: true, + enableCommandHistory: true, + enableTerminalToolbar: true, + }); + expect(await host("session-recording")).toMatchObject({ + enableSessionRecording: true, + }); + expect(await host("session-sharing")).toMatchObject({ + allowSessionSharing: true, + }); + expect(await host("docker")).toMatchObject({ enableDocker: false }); + expect(await host("tmux-monitor")).toMatchObject({ + enableTmuxMonitor: false, + }); + }); + + it("keeps each host's connection and secrets", async () => { + const { selectRows } = + await import("../../utils/crypto-migration/raw-rows.js"); + const hosts = await selectRows<{ + id: number; + auth_type: string; + enable_ssh: unknown; + }>(sql`SELECT id, auth_type, enable_ssh FROM ssh_data ORDER BY id`); + const byId = new Map(hosts.map((row) => [row.id, row])); + for (const [id, authType] of Object.entries(HOST_AUTH_TYPES)) { + // Warpgate became a plugin setting on a host with no SSH auth of its own. + const want = authType === "warpgate" ? "none" : authType; + expect(byId.get(Number(id))?.auth_type, `host ${id}`).toBe(want); + } + expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0); + + const ctx = await pluginCtx("remote-desktop"); + const target = await asUser(USERS.admin, () => + ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"), + ); + expect(target?.auth.password).toBe("d4-rdp-password"); + }); + + it("registers an SSH auth provider for every 2.8 auth type", async () => { + const { listSshAuthProviders } = + await import("../../hosts/connect/auth-provider-registry.js"); + const types = new Set(listSshAuthProviders().map((p) => p.type)); + for (const authType of new Set(Object.values(HOST_AUTH_TYPES))) { + if (authType === "warpgate") continue; + expect(types, authType).toContain(authType); + } + }); + + it("plays back every 2.8 recording", async () => { + const booted = current(); + const list = await get(booted, "/plugin-api/session-recording/"); + expect( + (list.body.logs as Array<{ id: number }>).map((log) => log.id).sort(), + ).toEqual([1, 2]); + for (const [id, kind] of [ + [1, "ssh"], + [2, "rdp"], + ] as const) { + const content = await get( + booted, + `/plugin-api/session-recording/${id}/content`, + ) + .buffer(true) + .parse((res, done) => { + let text = ""; + res.on("data", (chunk: Buffer) => (text += chunk.toString())); + res.on("end", () => done(null, text)); + }); + expect(content.status, `recording ${id}`).toBe(200); + expect(content.body).toContain(`d4 ${kind} recording`); + } + }); + + it("puts every moved setting in its plugin's admin and user scope", async () => { + const booted = current(); + expect(await settings(booted, "ssh-terminal", "admin")).toMatchObject({ + sessionTimeoutMinutes: 45, + sessionPersistence: false, + commandHistoryForNewHosts: false, + imageMaxCount: 7, + }); + expect(await settings(booted, "step-ca", "admin")).toMatchObject({ + caUrl: "https://ca.d4.example", + fingerprint: "d4fingerprint", + provisioner: "d4-provisioner", + }); + expect(await settings(booted, "remote-desktop", "admin")).toMatchObject({ + enabled: true, + guacdUrl: "guacd.d4.example:4822", + }); + expect(await settings(booted, "host-metrics", "admin")).toMatchObject({ + metricsInterval: 40, + historyRetentionDays: 14, + enabledForNewHosts: false, + }); + expect(await settings(booted, "ai", "admin")).toMatchObject({ + globallyEnabled: true, + }); + expect(await settings(booted, "session-recording", "admin")).toMatchObject({ + retentionDays: 3650, + }); + expect( + JSON.stringify((await settings(booted, "ai", "admin")).privateEndpoints), + ).toContain("10.4.0.0/24"); + expect(await settings(booted, "session-sharing", "admin")).toMatchObject({ + globallyEnabled: false, + }); + expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({ + domain: "termix.d4.example", + email: "d4@example.com", + }); + expect(await settings(booted, "tailscale", "admin")).toMatchObject({ + apiBaseUrl: "https://headscale.d4.example", + }); + expect(await settings(booted, "ai", "user")).toMatchObject({ + enabled: true, + allowReadOnlyCommands: true, + }); + expect(await settings(booted, "remote-desktop", "user")).toMatchObject({ + colorDepth: "16", + resizeMethod: "reconnect", + enableDrive: "on", + }); + }); + + it("keeps every secret readable by the plugin that owns it", async () => { + const tailscale = await pluginCtx("tailscale"); + expect(await tailscale.settings.get("apiKey")).toBe("tskey-d4"); + const acme = await pluginCtx("acme-ssl"); + expect(JSON.stringify(await acme.settings.getAll("admin"))).toContain( + "d4-cloudflare-token", + ); + const ai = await pluginCtx("ai"); + expect(await asUser(USERS.admin, () => ai.secrets.get("provider:1"))).toBe( + "sk-d4-provider-key", + ); + const sources = await pluginCtx("secret-sources"); + expect( + await asUser(USERS.admin, () => sources.secrets.get("source:d4-source")), + ).toBe("d4-source-token"); + }); + + it("keeps every role's permissions", async () => { + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const permissions = PermissionManager.getInstance(); + for (const permission of OPERATORS_PERMISSIONS) { + expect( + await permissions.hasPermission(USERS.alice, permission), + `operators: ${permission}`, + ).toBe(true); + } + for (const permission of USER_ROLE_PERMISSIONS_28) { + expect( + await permissions.hasPermission(USERS.passkey, permission), + `user: ${permission}`, + ).toBe(true); + } + expect(await permissions.hasPermission(USERS.admin, "sso.manage")).toBe( + true, + ); + }); + + it("still asks a 2.8 TOTP user for a code, and accepts one", async () => { + const booted = current(); + const login = await request(booted.app) + .post("/users/login") + .send({ username: "d4-totp", password: FIXTURE_PASSWORD }); + expect(login.status).toBe(200); + expect(login.body.requires_totp).toBe(true); + expect(JSON.stringify(login.body.second_factors)).toContain("totp"); + + const wrong = await request(booted.app) + .post("/users/totp/verify-login") + .send({ temp_token: login.body.temp_token, totp_code: "000000" }); + expect(wrong.status).not.toBe(200); + + const verify = await request(booted.app) + .post("/users/totp/verify-login") + .send({ + temp_token: login.body.temp_token, + totp_code: totpCode(TOTP_SECRET), + }); + expect(verify.status).toBe(200); + + const again = await request(booted.app) + .post("/users/login") + .send({ username: "d4-totp", password: FIXTURE_PASSWORD }); + const backup = await request(booted.app) + .post("/users/totp/verify-login") + .send({ + temp_token: again.body.temp_token, + totp_code: TOTP_BACKUP_CODES[bootIndex], + }); + expect(backup.status).toBe(200); + }); + + it("signs a password user without a second factor straight in", async () => { + const login = await request(current().app) + .post("/users/login") + .send({ username: "d4-alice", password: FIXTURE_PASSWORD }); + expect(login.status).toBe(200); + expect(login.body.requires_totp).toBeFalsy(); + }); + + it("links SSO and LDAP users to their providers", async () => { + const { selectRows } = + await import("../../utils/crypto-migration/raw-rows.js"); + const links = await selectRows<{ + user_id: string; + provider_id: unknown; + }>(sql`SELECT user_id, provider_id FROM user_external_identities`); + const byUser = new Map( + links.map((row) => [row.user_id, String(row.provider_id)]), + ); + expect(byUser.get(USERS.sso)).toBe(String(SSO_PROVIDER_ID)); + // The ldap plugin signs in with "ldap:". + expect(byUser.get(USERS.ldap)).toBe(`ldap:${LDAP_PROVIDER_ID}`); + }); + + it("syncs every entity type 2.8 synced, under the same name", async () => { + const { listEntityTypes } = await import("../../plugins/sync-registry.js"); + const { registerCoreSyncEntities } = + await import("../../database/routes/sync-entities.js"); + registerCoreSyncEntities(); + const known = new Set(listEntityTypes()); + for (const type of SYNCED_ENTITY_TYPES_28) { + expect(known, type).toContain(type); + } + + const snippets = await pluginRows("snippets", "p_snippets_snippets"); + expect(snippets.map((row) => row.sync_id)).toContain("d4-sync-snippet"); + const vault = await pluginRows("vault", "p_vault_profiles"); + expect(vault.map((row) => row.sync_id)).toContain("d4-sync-vault"); + }); +} + +let first: Booted; +let firstCounts: Record; +let second: Booted; + +beforeAll(async () => { + assertPluginsBuilt(); + first = await boot(); +}, BOOT_TIMEOUT); + +afterAll(async () => { + await (second ?? first)?.core.shutdown(); +}, BOOT_TIMEOUT); + +describe("a 2.8 install upgraded to 2.9.0", () => { + // Only SQLite is backed up by Termix; a server database is the operator's. + it.skipIf(remoteTestDialect())( + "backed up the 2.8 database before anything touched it", + async () => { + const backups = path.join(first.core.dataDir, "backups"); + const dirs = fs + .readdirSync(backups) + .filter((name) => name.startsWith("pre-plugin-runtime-")); + expect(dirs).toHaveLength(1); + const copy = fs.readFileSync(path.join(backups, dirs[0], "db.sqlite")); + expect(copy.equals(first.core.seed!)).toBe(true); + }, + ); + + upgradeChecks(() => first, 0); +}); + +describe("booting the upgraded install again", () => { + beforeAll(async () => { + firstCounts = await counts(); + const dataDir = first.core.dataDir; + await first.core.shutdown({ keepDataDir: true }); + second = await boot(dataDir); + }, BOOT_TIMEOUT); + + it("changes nothing", async () => { + expect(await counts()).toEqual(firstCounts); + }); + + it.skipIf(remoteTestDialect())("takes no second backup", () => { + const backups = fs.readdirSync(path.join(second.core.dataDir, "backups")); + expect( + backups.filter((name) => name.startsWith("pre-plugin-runtime-")), + ).toHaveLength(1); + }); + + upgradeChecks(() => second, 1); +}); diff --git a/src/backend/tests/upgrade/opkssh-config-migration.test.ts b/src/backend/tests/upgrade/opkssh-config-migration.test.ts index 2b3e227ba..78ca5fb67 100644 --- a/src/backend/tests/upgrade/opkssh-config-migration.test.ts +++ b/src/backend/tests/upgrade/opkssh-config-migration.test.ts @@ -43,7 +43,7 @@ import { runOpksshConfigMigration } from "../../upgrade/opkssh-config-migration. let dataDir: string; const legacy = () => path.join(dataDir, ".opk", "config.yml"); -const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml"); +const target = () => path.join(dataDir, "plugin-data", "opkssh", "config.yml"); beforeEach(async () => { state.settings = new Map(); diff --git a/src/backend/tests/upgrade/session-recording-data-migration.test.ts b/src/backend/tests/upgrade/session-recording-data-migration.test.ts new file mode 100644 index 000000000..eab5583ff --- /dev/null +++ b/src/backend/tests/upgrade/session-recording-data-migration.test.ts @@ -0,0 +1,157 @@ +/** + * The 2.8 session recording move. The retention period survives, so the + * plugin's first sweep does not prune with its own default, and each 2.8 + * recording file moves into the plugin's folder with its row following it. + * Running it twice changes nothing. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + db: null as unknown, + core: new Map(), + plugin: new Map(), + installed: true, +})); + +vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db })); +vi.mock("../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { databaseLogger: log }; +}); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => (h.installed ? { id } : null), + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => h.core.get(key) ?? null, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async (_p: string, _s: string, _id: string | null, key: string) => + h.plugin.has(key) ? { value: h.plugin.get(key) } : null, + set: async ( + _p: string, + _s: string, + _id: string | null, + key: string, + value: string, + ) => { + h.plugin.set(key, value); + }, + }), +})); + +const { runSessionRecordingDataMigration } = + await import("../../upgrade/session-recording-data-migration.js"); + +let dataDir: string; +let sqlite: Database.Database; + +function write(relative: string, content: string): string { + const file = path.join(dataDir, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; +} + +function paths(): Record { + return Object.fromEntries( + ( + sqlite + .prepare( + "SELECT id, recording_path FROM p_session_recording_session_recordings", + ) + .all() as Array<{ id: number; recording_path: string }> + ).map((row) => [row.id, row.recording_path]), + ); +} + +beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "recording-move-")); + vi.stubEnv("DATA_DIR", dataDir); + vi.stubEnv("SESSION_RECORDING_RETENTION_DAYS", ""); + h.core.clear(); + h.plugin.clear(); + h.installed = true; + sqlite = new Database(":memory:"); + sqlite.exec(`CREATE TABLE p_session_recording_session_recordings ( + id INTEGER PRIMARY KEY, recording_path TEXT + )`); + h.db = drizzle(sqlite); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("runSessionRecordingDataMigration", () => { + it("moves each 2.8 recording into the plugin's folder and points its row there", async () => { + const ssh = write("session_logs/user-1/s1.cast", "ssh"); + const rdp = write("session_recordings/guacamole/r1.guac", "rdp"); + const insert = sqlite.prepare( + "INSERT INTO p_session_recording_session_recordings (id, recording_path) VALUES (?, ?)", + ); + insert.run(1, ssh); + insert.run(2, rdp); + insert.run(3, path.join(dataDir, "session_logs/user-1/gone.cast")); + insert.run(4, "/somewhere/else.cast"); + + const result = await runSessionRecordingDataMigration(); + + const base = path.join(dataDir, "plugin-data", "session-recording"); + const moved = paths(); + expect(moved[1]).toBe(path.join(base, "session_logs/user-1/s1.cast")); + expect(moved[2]).toBe( + path.join(base, "session_recordings/guacamole/r1.guac"), + ); + expect(fs.readFileSync(moved[1], "utf8")).toBe("ssh"); + expect(fs.readFileSync(moved[2], "utf8")).toBe("rdp"); + expect(fs.existsSync(ssh)).toBe(false); + // A file 2.8 had already lost, and a path outside 2.8's folders, stay put. + expect(moved[3]).toBe(path.join(dataDir, "session_logs/user-1/gone.cast")); + expect(moved[4]).toBe("/somewhere/else.cast"); + expect(result.moved).toBe(2); + + const again = await runSessionRecordingDataMigration(); + expect(again.moved).toBe(0); + expect(paths()).toEqual(moved); + }); + + it("keeps the 2.8 retention period", async () => { + h.core.set("session_recording_retention_days", "365"); + await runSessionRecordingDataMigration(); + expect(h.plugin.get("retentionDays")).toBe("365"); + + h.core.set("session_recording_retention_days", "90"); + await runSessionRecordingDataMigration(); + expect(h.plugin.get("retentionDays")).toBe("365"); + }); + + it("falls back to the environment variable 2.8 read", async () => { + vi.stubEnv("SESSION_RECORDING_RETENTION_DAYS", "120"); + await runSessionRecordingDataMigration(); + expect(h.plugin.get("retentionDays")).toBe("120"); + }); + + it("does nothing until the plugin is installed", async () => { + h.installed = false; + h.core.set("session_recording_retention_days", "365"); + const ssh = write("session_logs/user-1/s1.cast", "ssh"); + sqlite + .prepare( + "INSERT INTO p_session_recording_session_recordings (id, recording_path) VALUES (1, ?)", + ) + .run(ssh); + + await runSessionRecordingDataMigration(); + + expect(h.plugin.size).toBe(0); + expect(paths()[1]).toBe(ssh); + }); +}); diff --git a/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts b/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts index dd5706637..3e85e2908 100644 --- a/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts +++ b/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts @@ -3,12 +3,15 @@ import fs from "fs"; import os from "os"; import path from "path"; import { + CURRENT_PREUPGRADE_BACKUP_REASON, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER, - DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX, DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV, - ensureDatabaseLayerPreupgradeBackup, + ensurePreupgradeBackup, + preupgradeBackupMarker, } from "../../utils/database-layer-preupgrade-backup.js"; +const MARKER = preupgradeBackupMarker(CURRENT_PREUPGRADE_BACKUP_REASON); + const tempDirs: string[] = []; function makeTempDir(): string { @@ -23,7 +26,7 @@ afterEach(() => { } }); -describe("ensureDatabaseLayerPreupgradeBackup", () => { +describe("ensurePreupgradeBackup", () => { it("copies existing database files and writes a marker", () => { const dataDir = makeTempDir(); const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted"); @@ -33,7 +36,7 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => { fs.writeFileSync(metadataPath, '{"version":"v2"}'); fs.writeFileSync(envPath, "DATABASE_KEY=test-key"); - const result = ensureDatabaseLayerPreupgradeBackup({ + const result = ensurePreupgradeBackup({ dataDir, version: "2.5.0-test", now: new Date("2026-06-27T12:00:00.000Z"), @@ -41,12 +44,8 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => { }); expect(result.status).toBe("created"); - expect(result.backupDir).toContain(DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX); - expect( - fs.existsSync( - path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), - ), - ).toBe(true); + expect(result.backupDir).toContain(CURRENT_PREUPGRADE_BACKUP_REASON); + expect(fs.existsSync(path.join(dataDir, MARKER))).toBe(true); expect( fs.readFileSync( path.join(result.backupDir!, "db.sqlite.encrypted"), @@ -72,12 +71,9 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => { it("skips when the marker already exists", () => { const dataDir = makeTempDir(); fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); - fs.writeFileSync( - path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), - "{}", - ); + fs.writeFileSync(path.join(dataDir, MARKER), "{}"); - const result = ensureDatabaseLayerPreupgradeBackup({ + const result = ensurePreupgradeBackup({ dataDir, env: {} as NodeJS.ProcessEnv, }); @@ -89,10 +85,37 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => { expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false); }); + it("still backs up an install that has the 2.5 refactor's marker", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), "2.8-db"); + fs.writeFileSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + "{}", + ); + + const first = ensurePreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }); + expect(first.status).toBe("created"); + expect( + fs.readFileSync(path.join(first.backupDir!, "db.sqlite"), "utf8"), + ).toBe("2.8-db"); + + const second = ensurePreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }); + expect(second).toMatchObject({ + status: "skipped", + reason: "marker_exists", + }); + }); + it("skips new installs without a database file", () => { const dataDir = makeTempDir(); - const result = ensureDatabaseLayerPreupgradeBackup({ + const result = ensurePreupgradeBackup({ dataDir, env: {} as NodeJS.ProcessEnv, }); @@ -101,18 +124,14 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => { status: "skipped", reason: "no_database_file", }); - expect( - fs.existsSync( - path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), - ), - ).toBe(false); + expect(fs.existsSync(path.join(dataDir, MARKER))).toBe(false); }); it("honors the explicit skip environment variable", () => { const dataDir = makeTempDir(); fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); - const result = ensureDatabaseLayerPreupgradeBackup({ + const result = ensurePreupgradeBackup({ dataDir, env: { [DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]: "1", @@ -132,7 +151,7 @@ describe("ensureDatabaseLayerPreupgradeBackup", () => { fs.writeFileSync(path.join(dataDir, "backups"), "not-a-directory"); expect(() => - ensureDatabaseLayerPreupgradeBackup({ + ensurePreupgradeBackup({ dataDir, env: {} as NodeJS.ProcessEnv, }), diff --git a/src/backend/upgrade/opkssh-config-migration.ts b/src/backend/upgrade/opkssh-config-migration.ts index 17b3441c5..da26c2ecb 100644 --- a/src/backend/upgrade/opkssh-config-migration.ts +++ b/src/backend/upgrade/opkssh-config-migration.ts @@ -2,7 +2,7 @@ * Moves a 2.8 OPKSSH setup into the opkssh plugin. * * The config file moves from DATA_DIR/.opk/config.yml to the plugin's own - * folder (DATA_DIR/plugins/opkssh/config.yml). The old file is copied, not + * folder (DATA_DIR/plugin-data/opkssh/config.yml). The old file is copied, not * moved, so a downgrade still finds it. An install that had a config also * keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because * that is the one registered with its identity providers: the plugin's @@ -55,7 +55,7 @@ export async function runOpksshConfigMigration( } try { - const target = path.join(dataDir, "plugins", PLUGIN_ID, "config.yml"); + const target = path.join(dataDir, "plugin-data", PLUGIN_ID, "config.yml"); if (!(await exists(target))) { await fs.mkdir(path.dirname(target), { recursive: true }); await fs.copyFile(legacyConfig, target); diff --git a/src/backend/upgrade/plugin-data-moves.ts b/src/backend/upgrade/plugin-data-moves.ts index 2ef60e793..559e6a4fe 100644 --- a/src/backend/upgrade/plugin-data-moves.ts +++ b/src/backend/upgrade/plugin-data-moves.ts @@ -8,9 +8,14 @@ import { runLdapProviderMigration } from "./ldap-provider-migration.js"; import { runOpksshConfigMigration } from "./opkssh-config-migration.js"; import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js"; +import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js"; +import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js"; export async function runPluginDataMoves(): Promise { await runLdapProviderMigration(); await runOpksshConfigMigration(); await runTermixIdentityCaMigration(); + // Both set how long history is kept, which the plugin prunes by on activate. + await runSessionRecordingDataMigration(); + await runHostMetricsSettingsMigration(); } diff --git a/src/backend/upgrade/session-recording-data-migration.ts b/src/backend/upgrade/session-recording-data-migration.ts new file mode 100644 index 000000000..8158259ff --- /dev/null +++ b/src/backend/upgrade/session-recording-data-migration.ts @@ -0,0 +1,167 @@ +/** + * Moves what a 2.8 install kept for session recordings into the + * session-recording plugin, before the plugin activates. Its retention sweep + * runs on activate, so both have to be in place first: + * + * - The retention period: the `session_recording_retention_days` setting, or + * the SESSION_RECORDING_RETENTION_DAYS environment variable 2.8 fell back + * to, becomes the plugin's `retentionDays`. Without it the plugin's 30 day + * default would prune an install that kept a year. + * - The files: 2.8 wrote them under DATA_DIR/session_logs (SSH) and + * DATA_DIR/session_recordings (guacd). The plugin only plays back and + * prunes files inside its own data folder, so each file a row points at is + * moved there, keeping its path below DATA_DIR, and the row follows it. + * Moved rather than copied: recordings can be large, and 2.8 cannot read + * the adopted table after a downgrade anyway. + * + * Idempotent: an existing retention value is kept, and a row that already + * points inside the plugin's folder is skipped. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import { sql } from "drizzle-orm"; +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; +import { + runStatement, + selectRows, +} from "../utils/crypto-migration/raw-rows.js"; +import { getPluginDataDir } from "../plugins/paths.js"; + +const PLUGIN_ID = "session-recording"; +const TABLE = "p_session_recording_session_recordings"; +const LEGACY_DIRS = ["session_logs", "session_recordings"]; + +export interface SessionRecordingDataMigrationResult { + retentionDays: number | null; + moved: number; +} + +function days(value: unknown): number | null { + const parsed = parseInt(String(value ?? ""), 10); + return parsed >= 1 && parsed <= 3650 ? parsed : null; +} + +async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +async function move(source: string, target: string): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }); + try { + await fs.rename(source, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error; + await fs.copyFile(source, target); + await fs.unlink(source); + } +} + +async function migrateRetention(): Promise { + const settings = createCurrentPluginSettingsRepository(); + const existing = await settings.get( + PLUGIN_ID, + "admin", + null, + "retentionDays", + ); + if (existing && existing.value !== null) return null; + + const legacy = + days( + await createCurrentSettingsRepository().get( + "session_recording_retention_days", + ), + ) ?? days(process.env.SESSION_RECORDING_RETENTION_DAYS); + if (legacy === null) return null; + + await settings.set( + PLUGIN_ID, + "admin", + null, + "retentionDays", + JSON.stringify(legacy), + ); + return legacy; +} + +async function migrateFiles(): Promise { + let rows: Array<{ id: number; recording_path: string | null }>; + try { + rows = await selectRows( + sql`SELECT id, recording_path FROM ${sql.identifier(TABLE)} WHERE recording_path IS NOT NULL`, + ); + } catch { + // The plugin has not adopted the table yet. + return 0; + } + + const dataDir = path.resolve(process.env.DATA_DIR || "./db/data"); + const pluginDir = getPluginDataDir(PLUGIN_ID); + let moved = 0; + + for (const row of rows) { + const source = path.resolve(row.recording_path!); + const relative = path.relative(dataDir, source); + const [top] = relative.split(path.sep); + if (relative.startsWith("..") || !LEGACY_DIRS.includes(top)) continue; + + const target = path.join(pluginDir, relative); + try { + if (!(await exists(target))) { + if (!(await exists(source))) continue; + await move(source, target); + } + await runStatement( + sql`UPDATE ${sql.identifier(TABLE)} SET recording_path = ${target} WHERE id = ${row.id}`, + ); + moved++; + } catch (error) { + databaseLogger.warn("Could not move a session recording file", { + operation: "session_recording_data_migration", + recordingId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return moved; +} + +export async function runSessionRecordingDataMigration(): Promise { + const result: SessionRecordingDataMigrationResult = { + retentionDays: null, + moved: 0, + }; + + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return result; + result.retentionDays = await migrateRetention(); + result.moved = await migrateFiles(); + } catch (error) { + databaseLogger.warn("Session recording data migration failed", { + operation: "session_recording_data_migration", + error: error instanceof Error ? error.message : String(error), + }); + return result; + } + + if (result.retentionDays !== null || result.moved > 0) { + databaseLogger.info("Moved 2.8 session recordings into the plugin", { + operation: "session_recording_data_migration", + retentionDays: result.retentionDays, + moved: result.moved, + }); + } + return result; +} diff --git a/src/backend/utils/database-layer-preupgrade-backup.ts b/src/backend/utils/database-layer-preupgrade-backup.ts index 70cf176e2..c809c2549 100644 --- a/src/backend/utils/database-layer-preupgrade-backup.ts +++ b/src/backend/utils/database-layer-preupgrade-backup.ts @@ -2,16 +2,33 @@ import fs from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; -export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER = - ".database-layer-preupgrade-backup.json"; -export const DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX = - "pre-database-layer-refactor"; +/** + * Each upgrade that rewrites the database takes its own backup, once. The 2.5 + * database layer refactor wrote the first marker, so a 2.8 install already has + * it; 2.9.0 moves data into plugins and needs a fresh copy regardless. + */ +export type PreupgradeBackupReason = + "pre-database-layer-refactor" | "pre-plugin-runtime"; + +export const CURRENT_PREUPGRADE_BACKUP_REASON: PreupgradeBackupReason = + "pre-plugin-runtime"; + +export function preupgradeBackupMarker(reason: PreupgradeBackupReason): string { + return reason === "pre-database-layer-refactor" + ? ".database-layer-preupgrade-backup.json" + : `.preupgrade-backup-${reason}.json`; +} + +export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER = preupgradeBackupMarker( + "pre-database-layer-refactor", +); export const DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV = "DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP"; const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV = "DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP"; interface PreupgradeBackupOptions { + reason?: PreupgradeBackupReason; dataDir?: string; version?: string; now?: Date; @@ -25,7 +42,7 @@ interface BackupFileEntry { } export interface PreupgradeBackupManifest { - reason: "pre-database-layer-refactor"; + reason: PreupgradeBackupReason; createdAt: string; sourceVersion: string; dataDir: string; @@ -71,15 +88,17 @@ function copyIfExists( }; } -function cleanupOldBackups(backupsRoot: string, keepCount: number): void { +function cleanupOldBackups( + backupsRoot: string, + prefix: string, + keepCount: number, +): void { if (!fs.existsSync(backupsRoot)) return; const entries = fs .readdirSync(backupsRoot, { withFileTypes: true }) .filter( - (entry) => - entry.isDirectory() && - entry.name.startsWith(`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-`), + (entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}-`), ) .map((entry) => { const fullPath = path.join(backupsRoot, entry.name); @@ -95,15 +114,13 @@ function cleanupOldBackups(backupsRoot: string, keepCount: number): void { } } -export function ensureDatabaseLayerPreupgradeBackup( +export function ensurePreupgradeBackup( options: PreupgradeBackupOptions = {}, ): PreupgradeBackupResult { + const reason = options.reason ?? CURRENT_PREUPGRADE_BACKUP_REASON; const env = options.env ?? process.env; const dataDir = path.resolve(options.dataDir ?? env.DATA_DIR ?? "./db/data"); - const markerPath = path.join( - dataDir, - DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER, - ); + const markerPath = path.join(dataDir, preupgradeBackupMarker(reason)); if ( TRUE_VALUES.has( @@ -139,9 +156,7 @@ export function ensureDatabaseLayerPreupgradeBackup( const backupsRoot = path.join(dataDir, "backups"); const backupDir = path.join( backupsRoot, - `${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-${timestampForPath( - options.now ?? new Date(), - )}`, + `${reason}-${timestampForPath(options.now ?? new Date())}`, ); try { @@ -152,7 +167,7 @@ export function ensureDatabaseLayerPreupgradeBackup( .filter((entry): entry is BackupFileEntry => entry !== null); const manifest: PreupgradeBackupManifest = { - reason: "pre-database-layer-refactor", + reason, createdAt: (options.now ?? new Date()).toISOString(), sourceVersion: options.version ?? env.VERSION ?? "unknown", dataDir, @@ -166,10 +181,11 @@ export function ensureDatabaseLayerPreupgradeBackup( ); fs.writeFileSync(markerPath, JSON.stringify(manifest, null, 2)); - cleanupOldBackups(backupsRoot, parseKeepCount(env)); + cleanupOldBackups(backupsRoot, reason, parseKeepCount(env)); databaseLogger.info("Database layer pre-upgrade backup created", { operation: "database_layer_preupgrade_backup_created", + reason, backupDir, files: files.map((file) => path.basename(file.backup)), });