Add dev/prod environments with TrueNAS Docker production deploy.

Split PostgreSQL into gallery_dev and gallery_prod, add Docker/Gitea deploy tooling,
SMB image sync, pgAdmin split script, dev:web on Keenetic :5173, and operator docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-04 15:15:19 +03:00
co-authored by Cursor
parent 02d238b043
commit 2edf577faf
34 changed files with 1742 additions and 80 deletions
+135
View File
@@ -0,0 +1,135 @@
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const pg = require('pg');
const {
assertProdDatabase,
assertDevDatabase,
loadDevPgConfig,
loadProdPgConfig,
rootDir,
} = require('./db-env');
const { Client } = pg;
const backupDir = path.join(rootDir, 'db', 'DataBackup');
const fromProd = process.argv.includes('--prod');
const config = fromProd ? loadProdPgConfig() : loadDevPgConfig();
if (fromProd) {
assertProdDatabase(config.database);
} else {
assertDevDatabase(config.database);
if (config.database.endsWith('_prod')) {
console.error('Refusing backup of production database without --prod. Use: npm run db:backup:prod');
process.exit(1);
}
}
function sqlLiteral(value) {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
if (value instanceof Date) return `'${value.toISOString()}'`;
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
if (typeof value === 'object') {
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
}
return `'${String(value).replace(/'/g, "''")}'`;
}
function timestampSlug(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'_',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
async function main() {
fs.mkdirSync(backupDir, { recursive: true });
const client = new Client(config);
await client.connect();
const dbNameRes = await client.query('SELECT current_database() AS name');
const dbName = dbNameRes.rows[0].name;
const tablesRes = await client.query(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`,
);
const lines = [
'-- Gallery PostgreSQL data backup',
`-- Generated: ${new Date().toISOString()}`,
`-- Database: ${dbName}`,
'-- Format: INSERT statements (data only)',
'',
];
let totalRows = 0;
for (const { table_name: tableName } of tablesRes.rows) {
const colsRes = await client.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position`,
[tableName],
);
const columns = colsRes.rows.map((row) => row.column_name);
const dataRes = await client.query(`SELECT * FROM "${tableName}"`);
const rowCount = dataRes.rowCount ?? 0;
totalRows += rowCount;
lines.push(`-- TABLE ${tableName} (${rowCount} rows)`);
if (rowCount === 0) {
lines.push('');
continue;
}
const quotedCols = columns.map((c) => `"${c}"`).join(', ');
for (const row of dataRes.rows) {
const values = columns.map((col) => sqlLiteral(row[col])).join(', ');
lines.push(`INSERT INTO "${tableName}" (${quotedCols}) VALUES (${values});`);
}
lines.push('');
}
await client.end();
const slug = timestampSlug();
const txtName = `${dbName}_data_${slug}.txt`;
const zipName = `${dbName}_data_${slug}.zip`;
const txtPath = path.join(backupDir, txtName);
const zipPath = path.join(backupDir, zipName);
fs.writeFileSync(txtPath, lines.join('\n'), 'utf8');
execFileSync(
'powershell',
[
'-NoProfile',
'-Command',
`Compress-Archive -LiteralPath '${txtPath.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`,
],
{ stdio: 'inherit' },
);
console.log(`Backup written: ${txtPath}`);
console.log(`Archive written: ${zipPath}`);
console.log(`Tables: ${tablesRes.rowCount}, rows: ${totalRows}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const rootDir = path.resolve(__dirname, '..');
const PROD_DB_SUFFIX = '_prod';
const DEV_DB_NAME = 'gallery_dev';
const PROD_DB_NAME = 'gallery_prod';
const LEGACY_DB_NAMES = ['Gallery', 'gallery'];
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
function pgConfigFromEnv() {
const host = process.env.DB_HOST || '192.168.10.122';
const port = Number(process.env.DB_PORT || 5432);
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
if (!user || !password || !database) {
throw new Error('DB_USER, DB_PASSWORD, and DB_NAME are required');
}
return { host, port, user, password, database };
}
function assertProdDatabase(dbName) {
if (!dbName.endsWith(PROD_DB_SUFFIX) && dbName !== PROD_DB_NAME) {
throw new Error(
`Refusing prod operation on database "${dbName}". `
+ `Target must be "${PROD_DB_NAME}" or end with "${PROD_DB_SUFFIX}".`,
);
}
return dbName;
}
function assertDevDatabase(dbName) {
if (dbName === PROD_DB_NAME || dbName.endsWith(PROD_DB_SUFFIX)) {
throw new Error(
`Refusing dev operation on production database "${dbName}". `
+ `Use "${DEV_DB_NAME}" for development.`,
);
}
return dbName;
}
function loadDevPgConfig() {
loadEnvFile(path.join(rootDir, '.env'));
const config = pgConfigFromEnv();
assertDevDatabase(config.database);
return config;
}
function loadProdPgConfig() {
loadEnvFile(path.join(rootDir, 'infra', 'docker', '.env.prod'));
const config = pgConfigFromEnv();
assertProdDatabase(config.database);
return config;
}
async function confirmProdAction(message) {
if (process.env.CONFIRM_PROD === '1') return;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) => {
rl.question(`${message}\nType "yes" to continue: `, resolve);
});
rl.close();
if (answer.trim().toLowerCase() !== 'yes') {
throw new Error('Aborted.');
}
}
module.exports = {
PROD_DB_SUFFIX,
DEV_DB_NAME,
PROD_DB_NAME,
LEGACY_DB_NAMES,
rootDir,
loadEnvFile,
pgConfigFromEnv,
assertProdDatabase,
assertDevDatabase,
loadDevPgConfig,
loadProdPgConfig,
confirmProdAction,
};
+43
View File
@@ -0,0 +1,43 @@
/**
* Start public dev stack: Vite on :5173, API on :3451 (or PORT from .env).
* Keenetic: devgallery.mysuperlab.netcraze.pro → 192.168.10.70:5173
*/
const { spawn } = require('child_process');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const apiPort = process.env.DEV_API_PORT || process.env.PORT || '3451';
const vitePort = process.env.DEV_WEB_PORT || '5173';
const env = { ...process.env, PORT: apiPort };
function run(label, command, args, cwd) {
const child = spawn(command, args, {
cwd,
env,
shell: true,
stdio: 'inherit',
});
child.on('exit', (code) => {
if (code && code !== 0) {
console.error(`${label} exited with code ${code}`);
}
});
return child;
}
console.log(`Gallery dev:web — UI http://0.0.0.0:${vitePort} API http://127.0.0.1:${apiPort}`);
console.log(`Set PUBLIC_URL=http://devgallery.mysuperlab.netcraze.pro in .env for Keenetic`);
const api = run('API', 'npx', ['nodemon', 'server/index.js'], path.join(__dirname, '..'));
const client = run('Vite', 'npm', ['run', 'dev', '--prefix', 'client', '--', '--port', vitePort, '--host'], path.join(__dirname, '..'));
function shutdown() {
api.kill('SIGTERM');
client.kill('SIGTERM');
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+104
View File
@@ -0,0 +1,104 @@
/**
* Restore a data-only backup (INSERT dumps from npm run db:backup) into a database.
*
* Dev: node scripts/restore-db-data.js --file db/DataBackup/gallery_dev_data_....txt
* Prod: npm run db:restore:prod -- --file db/DataBackup/gallery_dev_data_....txt
*/
const fs = require('fs');
const path = require('path');
const pg = require('pg');
const {
assertProdDatabase,
assertDevDatabase,
confirmProdAction,
loadDevPgConfig,
loadProdPgConfig,
} = require('./db-env');
const { Client } = pg;
function parseArgs(argv) {
const fileIdx = argv.indexOf('--file');
if (fileIdx === -1 || !argv[fileIdx + 1]) {
throw new Error('--file <path> is required');
}
return {
filePath: path.resolve(argv[fileIdx + 1]),
prod: argv.includes('--prod'),
};
}
function extractInsertStatements(content) {
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith('INSERT INTO '));
}
async function truncatePublicTables(client) {
const tablesRes = await client.query(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`,
);
const names = tablesRes.rows.map((r) => `"${r.table_name}"`).join(', ');
if (!names) return;
await client.query(`TRUNCATE TABLE ${names} RESTART IDENTITY CASCADE`);
}
async function main() {
const { filePath, prod } = parseArgs(process.argv.slice(2));
if (!fs.existsSync(filePath)) {
throw new Error(`Backup file not found: ${filePath}`);
}
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
const dbName = config.database;
const inserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8'));
if (inserts.length === 0) {
throw new Error('No INSERT statements found in backup file');
}
const prompt = prod
? `RESTORE ${inserts.length} rows into PRODUCTION "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`
: `Restore ${inserts.length} rows into "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`;
if (prod) {
assertProdDatabase(dbName);
} else {
assertDevDatabase(dbName);
}
await confirmProdAction(prompt);
const client = new Client(config);
await client.connect();
console.log(`Truncating public tables in "${dbName}"...`);
await truncatePublicTables(client);
console.log(`Restoring ${inserts.length} INSERT statements...`);
await client.query('SET session_replication_role = replica');
let restored = 0;
try {
for (const statement of inserts) {
await client.query(statement);
restored += 1;
if (restored % 500 === 0) {
console.log(` ${restored}/${inserts.length}`);
}
}
} finally {
await client.query('SET session_replication_role = DEFAULT');
}
await client.end();
console.log(`Restore complete: ${restored} statements into "${dbName}".`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+143
View File
@@ -0,0 +1,143 @@
/**
* One-time split of legacy Gallery → gallery_prod + gallery_dev on TrueNAS PostgreSQL.
*
* Usage:
* PGPASSWORD=... PGHOST=192.168.10.122 PGUSER=postgres npm run db:split-databases
*
* Options:
* --copy pg_dump legacy → gallery_prod instead of rename
* --skip-dev Do not create gallery_dev
*/
const { execFileSync } = require('child_process');
const pg = require('pg');
const {
DEV_DB_NAME,
LEGACY_DB_NAMES,
PROD_DB_NAME,
} = require('./db-env');
const { Client } = pg;
const args = new Set(process.argv.slice(2));
const useCopy = args.has('--copy');
const skipDev = args.has('--skip-dev');
const host = process.env.PGHOST ?? process.env.DB_HOST ?? '192.168.10.122';
const port = Number(process.env.PGPORT ?? process.env.DB_PORT ?? 5432);
const user = process.env.PGUSER ?? process.env.DB_USER ?? 'postgres';
const password = process.env.PGPASSWORD ?? process.env.DB_PASSWORD;
if (!password) {
console.error('PGPASSWORD (or DB_PASSWORD) is required');
process.exit(1);
}
function quoteIdent(ident) {
return `"${ident.replace(/"/g, '""')}"`;
}
async function databaseExists(client, name) {
const res = await client.query(
'SELECT 1 FROM pg_database WHERE datname = $1',
[name],
);
return res.rowCount > 0;
}
async function findLegacyName(client) {
for (const name of LEGACY_DB_NAMES) {
if (await databaseExists(client, name)) return name;
}
return null;
}
async function createDatabaseIfMissing(admin, name) {
if (await databaseExists(admin, name)) {
console.log(`Database "${name}" already exists`);
return false;
}
await admin.query(`CREATE DATABASE ${quoteIdent(name)}`);
console.log(`Created database "${name}"`);
return true;
}
async function terminateConnections(admin, dbName) {
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[dbName],
);
}
async function cloneDevFromProd(admin) {
if (await databaseExists(admin, DEV_DB_NAME)) {
console.log(`"${DEV_DB_NAME}" already exists — skipping dev clone`);
return;
}
await terminateConnections(admin, PROD_DB_NAME);
console.log(`Creating "${DEV_DB_NAME}" from TEMPLATE "${PROD_DB_NAME}"...`);
await admin.query(
`CREATE DATABASE ${quoteIdent(DEV_DB_NAME)} WITH TEMPLATE ${quoteIdent(PROD_DB_NAME)} OWNER ${quoteIdent(user)}`,
);
console.log(`Created "${DEV_DB_NAME}" as a full copy of "${PROD_DB_NAME}"`);
}
async function main() {
const admin = new Client({ host, port, user, password, database: 'postgres' });
await admin.connect();
const legacyName = await findLegacyName(admin);
const hasProd = await databaseExists(admin, PROD_DB_NAME);
if (hasProd) {
console.log(`"${PROD_DB_NAME}" already exists — skipping prod migration`);
} else if (legacyName) {
if (useCopy) {
await createDatabaseIfMissing(admin, PROD_DB_NAME);
console.log(`Copying ${legacyName}${PROD_DB_NAME} via pg_dump...`);
const dump = execFileSync(
'pg_dump',
['-h', host, '-p', String(port), '-U', user, '-d', legacyName, '--no-owner', '--no-acl'],
{ env: { ...process.env, PGPASSWORD: password }, encoding: 'buffer' },
);
execFileSync(
'psql',
['-h', host, '-p', String(port), '-U', user, '-d', PROD_DB_NAME, '-v', 'ON_ERROR_STOP=1'],
{ env: { ...process.env, PGPASSWORD: password }, input: dump, stdio: ['pipe', 'inherit', 'inherit'] },
);
console.log(`Copied ${legacyName}${PROD_DB_NAME}`);
} else {
await terminateConnections(admin, legacyName);
await admin.query(`ALTER DATABASE ${quoteIdent(legacyName)} RENAME TO ${quoteIdent(PROD_DB_NAME)}`);
console.log(`Renamed ${legacyName}${PROD_DB_NAME}`);
}
} else {
console.warn(`No legacy database (${LEGACY_DB_NAMES.join(' / ')}) or "${PROD_DB_NAME}" found`);
await createDatabaseIfMissing(admin, PROD_DB_NAME);
console.log('Run npm run migrate against gallery_prod to apply schema');
}
if (!skipDev) {
await cloneDevFromProd(admin);
console.log(`Next on dev PC: set .env DB_NAME=${DEV_DB_NAME}, then npm run migrate`);
}
await admin.end();
console.log('Done. TrueNAS compose.truenas.yaml should use DB_NAME=gallery_prod.');
}
main().catch((error) => {
if (error.code === '42501') {
console.error(
'Permission denied — the gallery user cannot rename databases.\n'
+ 'Use pgAdmin on the dev PC (recommended):\n'
+ ' Open db/split-dev-prod-pgadmin.sql → connect as postgres → run each STEP (F5)\n'
+ 'Or: PGUSER=postgres PGPASSWORD=... npm run db:split-databases\n'
+ 'Or: psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql',
);
}
console.error(error.message || error);
process.exit(1);
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Clone gallery_prod → gallery_dev (PostgreSQL TEMPLATE).
* Requires superuser or CREATEDB on the Postgres host.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const pg = require('pg');
const {
DEV_DB_NAME,
PROD_DB_NAME,
loadProdPgConfig,
rootDir,
} = require('./db-env');
const { Client } = pg;
function quoteIdent(ident) {
return `"${ident.replace(/"/g, '""')}"`;
}
async function main() {
const prodConfig = loadProdPgConfig();
const { host, port, user, password } = prodConfig;
const admin = new Client({ host, port, user, password, database: 'postgres' });
await admin.connect();
console.log(`Terminating connections to "${DEV_DB_NAME}"...`);
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[DEV_DB_NAME],
);
const exists = await admin.query(
'SELECT 1 FROM pg_database WHERE datname = $1',
[DEV_DB_NAME],
);
if (exists.rowCount > 0) {
console.log(`Dropping "${DEV_DB_NAME}"...`);
await admin.query(`DROP DATABASE ${quoteIdent(DEV_DB_NAME)}`);
}
console.log(`Terminating connections to "${PROD_DB_NAME}"...`);
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[PROD_DB_NAME],
);
console.log(`Cloning "${PROD_DB_NAME}" → "${DEV_DB_NAME}" (TEMPLATE)...`);
await admin.query(
`CREATE DATABASE ${quoteIdent(DEV_DB_NAME)} WITH TEMPLATE ${quoteIdent(PROD_DB_NAME)} OWNER ${quoteIdent(user)}`,
);
await admin.end();
const verify = new Client({ host, port, user, password, database: DEV_DB_NAME });
await verify.connect();
const tables = await verify.query(
`SELECT count(*)::int AS n FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
);
const paintings = await verify.query('SELECT count(*)::int AS n FROM paintings');
await verify.end();
console.log(`Done. "${DEV_DB_NAME}" is a copy of "${PROD_DB_NAME}" (${tables.rows[0].n} tables, ${paintings.rows[0].n} paintings).`);
console.log('Syncing image files from production...');
execFileSync('powershell', ['-NoProfile', '-File', path.join(rootDir, 'infra', 'scripts', 'sync-images-from-prod.ps1'), '-SkipConfirm'], {
cwd: rootDir,
stdio: 'inherit',
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});