diff --git a/scripts/upgrade-check.mjs b/scripts/upgrade-check.mjs new file mode 100644 index 000000000..27085e37a --- /dev/null +++ b/scripts/upgrade-check.mjs @@ -0,0 +1,193 @@ +/** + * Builds a 2.9.1 database on a real Postgres or MySQL server, seeds the rows + * 26.10.0 rewrites or drops, then runs the real startup over it and checks + * the result. 2.9.1 is the oldest database this version opens. + * + * Run through scripts/upgrade-check.sh, which starts the servers: + * tsx scripts/upgrade-check.mjs + * + * Wipes the target database first. + */ + +import path from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { getTableColumns, getTableName, is, sql, Table } from "drizzle-orm"; + +const [dialect, url, legacyRoot] = process.argv.slice(2); +if (!["postgres", "mysql"].includes(dialect) || !url || !legacyRoot) { + console.error( + "usage: tsx scripts/upgrade-check.mjs ", + ); + process.exit(2); +} + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const load = (file) => import(pathToFileURL(path.join(ROOT, file)).href); + +const { drizzle } = await import( + dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2" +); +const db = drizzle(url); +const rows = (result) => + Array.isArray(result) ? result[0] : (result.rows ?? []); +// Written with Postgres quoting; MySQL gets backticks. +const exec = (text) => + db.execute(sql.raw(dialect === "postgres" ? text : text.replace(/"/g, "`"))); +const one = async (text) => rows(await exec(text))[0]; + +if (dialect === "postgres") { + await exec(`DROP SCHEMA public CASCADE`); + await exec(`CREATE SCHEMA public`); + await exec(`DROP SCHEMA IF EXISTS drizzle CASCADE`); +} else { + const tables = rows( + await exec( + `SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE()`, + ), + ); + await exec(`SET FOREIGN_KEY_CHECKS = 0`); + for (const { name } of tables) await exec(`DROP TABLE IF EXISTS "${name}"`); + await exec(`SET FOREIGN_KEY_CHECKS = 1`); +} + +const { migrate } = + dialect === "postgres" + ? await import("drizzle-orm/node-postgres/migrator") + : await import("drizzle-orm/mysql2/migrator"); +await migrate(db, { migrationsFolder: path.join(legacyRoot, dialect) }); +console.log(`${dialect}: 2.9.1 schema built`); + +const yes = dialect === "postgres" ? "true" : "1"; +await exec( + `INSERT INTO "users" ("id", "username", "password_hash", "is_admin", "is_oidc", "oidc_identifier", "client_id") VALUES ('u1', 'alice', 'x', ${yes}, ${yes}, 'sub-1', 'cid')`, +); +await exec( + `INSERT INTO "plugins" ("id", "name", "version", "state", "manifest_json") VALUES ('snippets', 'Snippets', '1.0.0', 'enabled', '{}')`, +); +await exec( + `INSERT INTO "ssh_credentials" ("id", "user_id", "name", "auth_type", "username") VALUES (1, 'u1', 'cred', 'password', '$oidc.preferred_username')`, +); +await exec( + `INSERT INTO "ssh_folders" ("id", "user_id", "name", "credential_id") VALUES (1, 'u1', 'f', 1)`, +); +await exec( + `INSERT INTO "ssh_data" ("id", "user_id", "name", "ip", "port", "username", "auth_type", "enable_rdp", "folder") VALUES (1, 'u1', 'h', '10.0.0.1', 22, '$oidc.preferred_username', 'password', ${yes}, 'f')`, +); +// Its JSON quotes must not become backticks. +await db.execute( + sql`UPDATE ssh_data SET terminal_config = ${JSON.stringify({ sudoPassword: "s3cret" })} WHERE id = 1`, +); +await exec( + `INSERT INTO "sessions" ("id", "user_id", "jwt_token", "device_type", "device_info", "expires_at", "oidc_sub") VALUES ('s1', 'u1', 'jwt', 'web', 'ua', '2099-01-01', 'sub')`, +); +await exec( + `INSERT INTO "settings" ("key", "value") VALUES ('host_defaults', '{}'), ('allow_registration', 'true'), ('guac_url', 'x')`, +); +await exec( + `INSERT INTO "user_preferences" ("user_id", "terminal_macros", "rdp_defaults") VALUES ('u1', '[]', '{}')`, +); + +process.env.DATABASE_DIALECT = dialect; +process.env.DATABASE_URL = url; +process.env.DRIZZLE_MIGRATIONS_DIR = path.join(ROOT, "drizzle"); +const { initializeDatabase } = await load("src/backend/database/db/index.ts"); +await initializeDatabase(); + +let failures = 0; +const check = (label, ok, detail) => { + if (!ok) failures++; + console.log( + ` ${ok ? "ok " : "FAIL"} ${label}${ok ? "" : ` ${JSON.stringify(detail)}`}`, + ); +}; + +const schemaName = dialect === "postgres" ? "current_schema()" : "DATABASE()"; +const columns = new Set( + rows( + await exec( + `SELECT table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = ${schemaName}`, + ), + ).map((row) => `${row.t}.${row.c}`), +); + +const { RETIRED_COLUMNS, REMOTE_RETIRED_COLUMNS } = await load( + "src/backend/database/db/retired-columns.ts", +); +const leftover = [RETIRED_COLUMNS, REMOTE_RETIRED_COLUMNS].flatMap((group) => + Object.entries(group).flatMap(([table, names]) => + names.map((name) => `${table}.${name}`).filter((key) => columns.has(key)), + ), +); +check("retired columns are gone", leftover.length === 0, leftover); + +const schema = await load( + `src/backend/database/db/schema.${dialect === "postgres" ? "pg" : "mysql"}.ts`, +); +const missing = Object.values(schema) + .filter((value) => is(value, Table)) + .flatMap((table) => + Object.values(getTableColumns(table)) + .map((column) => `${getTableName(table)}.${column.name}`) + .filter((key) => !columns.has(key)), + ); +check("every schema column exists", missing.length === 0, missing); + +check( + "user kept", + (await one(`SELECT "username" FROM "users" WHERE "id" = 'u1'`))?.username === + "alice", +); +const host = await one( + `SELECT "username", "sudo_password" FROM "ssh_data" WHERE "id" = 1`, +); +check( + "host username placeholder rewritten", + host?.username === "$external.username", + host, +); +check( + "sudo password moved out of terminal_config", + host?.sudo_password === "s3cret", + host, +); +const credential = await one( + `SELECT "username" FROM "ssh_credentials" WHERE "id" = 1`, +); +check( + "credential username placeholder rewritten", + credential?.username === "$external.username", + credential, +); +check( + "folder kept", + !!(await one(`SELECT "id" FROM "ssh_folders" WHERE "id" = 1`)), +); +check( + "session kept", + !!(await one(`SELECT "id" FROM "sessions" WHERE "id" = 's1'`)), +); +const keys = rows(await exec(`SELECT "key" FROM "settings"`)).map( + (row) => row.key, +); +check( + "retired settings deleted", + !keys.includes("host_defaults") && !keys.includes("guac_url"), + keys, +); +check("other settings kept", keys.includes("allow_registration"), keys); +check( + "system roles seeded", + rows(await exec(`SELECT "id" FROM "roles"`)).length > 0, +); + +try { + await initializeDatabase(); + check("a second start changes nothing", true); +} catch (error) { + check("a second start changes nothing", false, String(error)); +} + +console.log( + `\n${dialect}: ${failures ? `${failures} check(s) failed` : "all checks passed"}`, +); +process.exit(failures ? 1 : 0); diff --git a/scripts/upgrade-check.sh b/scripts/upgrade-check.sh new file mode 100644 index 000000000..9c949818e --- /dev/null +++ b/scripts/upgrade-check.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Upgrades a 2.9.1 database to this tree on real Postgres and MySQL servers, +# then runs the dialect-aware backend tests against both. +# +# Needs Docker and the 2.9.1 tag (git fetch --tags). Run from anywhere: +# bash scripts/upgrade-check.sh # both engines +# bash scripts/upgrade-check.sh mysql # one engine +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TAG="release-2.9.1-tag" +PG="termix-upgrade-check-pg" +MY="termix-upgrade-check-mysql" +if [ "$#" -gt 0 ]; then + DIALECTS=("$@") +else + DIALECTS=(postgres mysql) +fi + +cd "$ROOT" + +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 + +WORK="$(mktemp -d)" +cleanup() { + docker rm -f "$PG" "$MY" >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT + +git archive "$TAG" drizzle | tar -x -C "$WORK" +mkdir -p "$WORK/data" + +docker rm -f "$PG" "$MY" >/dev/null 2>&1 || true +docker run -d --name "$PG" -e POSTGRES_USER=termix -e POSTGRES_PASSWORD=termix \ + -e POSTGRES_DB=termix -p 55433:5432 postgres:16 >/dev/null +docker run -d --name "$MY" -e MYSQL_ROOT_PASSWORD=termix -e MYSQL_DATABASE=termix \ + -p 33307:3306 mysql:8 >/dev/null + +wait_for() { + for _ in $(seq 1 60); do + if "$@" >/dev/null 2>&1; then return 0; fi + sleep 2 + done + echo "Timed out waiting for: $*" >&2 + exit 1 +} +wait_for docker exec "$PG" pg_isready -U termix -d termix +wait_for docker exec "$MY" mysql -uroot -ptermix -e "SELECT 1" +docker exec "$PG" psql -U termix -c "CREATE DATABASE termix_test" >/dev/null +docker exec "$MY" mysql -uroot -ptermix -e "CREATE DATABASE termix_test" 2>/dev/null + +TESTS=$(grep -rl "test-support" src/backend/tests --include="*.test.ts") + +status=0 +for dialect in "${DIALECTS[@]}"; do + case "$dialect" in + postgres) base="postgres://termix:termix@127.0.0.1:55433" ;; + mysql) base="mysql://root:termix@127.0.0.1:33307" ;; + *) + echo "Unknown dialect $dialect (postgres or mysql)" >&2 + exit 2 + ;; + esac + echo + echo "== 2.9.1 to this tree on $dialect" + if ! DATA_DIR="$WORK/data" npx tsx scripts/upgrade-check.mjs \ + "$dialect" "$base/termix" "$WORK/drizzle"; then + status=1 + fi + echo + echo "== dialect tests on $dialect" + # One file at a time: they share the database and wipe it between tests. + # shellcheck disable=SC2086 + if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$base/termix_test" \ + npx vitest run --project backend --no-file-parallelism $TESTS; then + status=1 + fi +done + +exit "$status"