mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
This commit is contained in:
1 parent
2ee9b3592c
commit
f779d6a08e
37 files changed
+5184
-377
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
await runLdapProviderMigration();
|
||||
await runOpksshConfigMigration();
|
||||
await runTermixIdentityCaMigration();
|
||||
// Both set how long history is kept, which the plugin prunes by on activate.
|
||||
await runSessionRecordingDataMigration();
|
||||
await runHostMetricsSettingsMigration();
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
await fs.access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function move(source: string, target: string): Promise<void> {
|
||||
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<number | null> {
|
||||
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<number> {
|
||||
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<SessionRecordingDataMigrationResult> {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user