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
+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);
});