Skip to content

Commit 2108025

Browse files
authored
Adds database management API calls and update crons (#1301)
Includes: * DatabaseController which consists of 3 functions (dumping the postgres database, restoring the postgres database and cleaning the folder of dumps if required) * Max files allowed in ~/db_dumps to be 28 instead
1 parent 0d09b37 commit 2108025

File tree

4 files changed

+345
-3
lines changed

4 files changed

+345
-3
lines changed
+40-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,52 @@
11
---
22
# Create a cron to import the most recent tests from aria-at
3-
- name: Set a cron job to build test results
3+
- name: Set a cron job to build and import latest test versions from aria-at
44
cron:
55
name: "import latest aria-at tests"
66
minute: "15"
77
job: "curl -X POST https://{{fqdn}}/api/test/import"
8-
when: deployment_mode != 'development'
8+
when: deployment_mode != 'development'
99

10-
- name: Set a cron job to build test results in development
10+
- name: Set a cron job to build and import latest test versions from aria-at in development
1111
cron:
1212
name: "import latest aria-at tests"
1313
minute: "15"
1414
job: "curl -X POST http://localhost:5000/api/test/import"
1515
when: deployment_mode == 'development'
16+
17+
- name: Ensure proper permissions for application_user on db_dumps directory
18+
become: yes
19+
block:
20+
- name: Ensure the db_dumps directory exists
21+
file:
22+
path: /home/{{application_user}}/db_dumps
23+
state: directory
24+
owner: '{{application_user}}'
25+
group: '{{application_user}}'
26+
mode: '0755'
27+
28+
- name: Ensure application_user has write permissions on the db_dumps directory
29+
file:
30+
path: /home/{{application_user}}/db_dumps
31+
owner: '{{application_user}}'
32+
group: '{{application_user}}'
33+
mode: '0775'
34+
when: deployment_mode == 'staging' or deployment_mode == 'production'
35+
36+
# Create a cron to dump the database in staging and production (run every day at 00:00)
37+
- name: Set a cron job to create a new database dump
38+
cron:
39+
name: "create new database dump"
40+
hour: "0"
41+
minute: "0"
42+
job: "curl -X POST https://{{fqdn}}/api/database/dump"
43+
when: deployment_mode == 'staging' or deployment_mode == 'production'
44+
45+
# Create a cron to clean up the database dumps folder in staging and production (run every day at 00:05)
46+
- name: Set a cron job to clean up the database dumps folder
47+
cron:
48+
name: "clean up the database dumps folder if necessary"
49+
hour: "0"
50+
minute: "5"
51+
job: "curl -X POST https://{{fqdn}}/api/database/cleanFolder"
52+
when: deployment_mode == 'staging' or deployment_mode == 'production'

server/app.js

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const authRoutes = require('./routes/auth');
88
const testRoutes = require('./routes/tests');
99
const transactionRoutes = require('./routes/transactions');
1010
const automationSchedulerRoutes = require('./routes/automation');
11+
const databaseRoutes = require('./routes/database');
1112
const path = require('path');
1213
const apolloServer = require('./graphql-server');
1314
const {
@@ -24,6 +25,7 @@ app.use('/auth', authRoutes);
2425
app.use('/test', testRoutes);
2526
app.use('/transactions', transactionRoutes);
2627
app.use('/jobs', automationSchedulerRoutes);
28+
app.use('/database', databaseRoutes);
2729

2830
apolloServer.start().then(() => {
2931
apolloServer.applyMiddleware({ app });
+289
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const os = require('os');
4+
const { Client } = require('pg');
5+
const { exec } = require('child_process');
6+
const { dates } = require('shared');
7+
8+
// Database configuration
9+
const DB_NAME = process.env.PGDATABASE;
10+
const DB_USER = process.env.PGUSER;
11+
const DB_PASSWORD = process.env.PGPASSWORD;
12+
const DB_HOST = process.env.PGHOST;
13+
14+
// To avoid unintentionally exposing environment variables in error messages
15+
const sanitizeError = message => {
16+
const redacted = '#redacted#';
17+
18+
const dbNameRegex = new RegExp(DB_NAME, 'g');
19+
const dbUserRegex = new RegExp(DB_USER, 'g');
20+
const dbPasswordRegex = new RegExp(DB_PASSWORD, 'g');
21+
const dbHostRegex = new RegExp(DB_HOST, 'g');
22+
23+
return message
24+
.replace(dbNameRegex, redacted)
25+
.replace(dbUserRegex, redacted)
26+
.replace(dbPasswordRegex, redacted)
27+
.replace(dbHostRegex, redacted);
28+
};
29+
30+
const dumpPostgresDatabaseToFile = (dumpFileName = null) => {
31+
const OUTPUT_DIR = path.join(os.homedir(), 'db_dumps');
32+
const DUMP_FILE = path.join(
33+
OUTPUT_DIR,
34+
dumpFileName ||
35+
`${process.env.ENVIRONMENT}_dump_${dates.convertDateToString(
36+
new Date(),
37+
'YYYYMMDD_HHmmss'
38+
)}.sql`
39+
);
40+
41+
return new Promise((resolve, reject) => {
42+
try {
43+
// pg_dump command that ignores the table "session" because it causes unnecessary conflicts when being used to restore a db
44+
const pgDumpCommand = `PGPASSWORD=${DB_PASSWORD} pg_dump -U ${DB_USER} -h ${DB_HOST} -d ${DB_NAME} --exclude-table=session > ${DUMP_FILE}`;
45+
exec(pgDumpCommand, (error, stdout, stderr) => {
46+
if (error) {
47+
return reject(
48+
new Error(
49+
`Error executing pg_dump: ${sanitizeError(error.message)}`
50+
)
51+
);
52+
}
53+
if (stderr) {
54+
return reject(new Error(`pg_dump stderr: ${sanitizeError(stderr)}`));
55+
}
56+
return resolve(`Database dump completed successfully: ${DUMP_FILE}`);
57+
});
58+
} catch (error) {
59+
return reject(
60+
new Error(`Unable to dump database: ${sanitizeError(error.message)}`)
61+
);
62+
}
63+
});
64+
};
65+
66+
const removeDatabaseTablesAndFunctions = async res => {
67+
const client = new Client({
68+
user: DB_USER,
69+
host: DB_HOST,
70+
database: DB_NAME,
71+
password: DB_PASSWORD
72+
});
73+
74+
try {
75+
await client.connect();
76+
res.write('Connected to the database.\n');
77+
78+
// [Tables DROP // START]
79+
// Get all tables in the public schema
80+
const tablesQuery = `
81+
SELECT tablename
82+
FROM pg_tables
83+
WHERE schemaname = 'public';
84+
`;
85+
const tablesResult = await client.query(tablesQuery);
86+
87+
// Execute DROP TABLE commands
88+
const tableNames = tablesResult.rows
89+
// Ignore 'session' because dropping it may cause unnecessary conflicts
90+
// when restoring
91+
.filter(row => row.tablename !== 'session')
92+
.map(row => `"${row.tablename}"`);
93+
if (tableNames.length === 0) {
94+
res.write('No tables found to drop.\n');
95+
} else {
96+
// eslint-disable-next-line no-console
97+
console.info(`Dropping tables: ${tableNames.join(', ')}`);
98+
await client.query(`DROP TABLE ${tableNames.join(', ')} CASCADE;`);
99+
res.write('All tables have been dropped successfully.\n');
100+
}
101+
// [Tables DROP // END]
102+
103+
// [Functions DROP // START]
104+
// Get all user-defined functions
105+
const functionsQuery = `
106+
SELECT 'DROP FUNCTION IF EXISTS ' || n.nspname || '.' || p.proname || '(' ||
107+
pg_get_function_identity_arguments(p.oid) || ');' AS drop_statement
108+
FROM pg_proc p
109+
JOIN pg_namespace n ON p.pronamespace = n.oid
110+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema');
111+
`;
112+
113+
const functionsResult = await client.query(functionsQuery);
114+
const dropStatements = functionsResult.rows.map(row => row.drop_statement);
115+
116+
// Execute each DROP FUNCTION statement
117+
for (const dropStatement of dropStatements) {
118+
// eslint-disable-next-line no-console
119+
console.info(`Executing: ${dropStatement}`);
120+
await client.query(dropStatement);
121+
}
122+
res.write('All functions removed successfully!\n');
123+
// [Functions DROP // END]
124+
} catch (error) {
125+
res.write(
126+
`Error removing tables or functions: ${sanitizeError(error.message)}\n`
127+
);
128+
} finally {
129+
await client.end();
130+
res.write('Disconnected from the database.\n');
131+
}
132+
};
133+
134+
const dumpPostgresDatabase = async (req, res) => {
135+
const { dumpFileName } = req.body;
136+
137+
if (dumpFileName && path.extname(dumpFileName) !== '.sql')
138+
return res.status(400).send("Provided file name does not include '.sql'");
139+
140+
try {
141+
const result = await dumpPostgresDatabaseToFile();
142+
return res.status(200).send(result);
143+
} catch (error) {
144+
return res.status(400).send(error.message);
145+
}
146+
};
147+
148+
const restorePostgresDatabase = async (req, res) => {
149+
// Prevent unintentionally dropping or restoring database tables if ran on
150+
// production unless manually done
151+
if (
152+
process.env.ENVIRONMENT === 'production' ||
153+
process.env.API_SERVER === 'https://aria-at.w3.org' ||
154+
req.hostname.includes('aria-at.w3.org')
155+
) {
156+
return res.status(405).send('This request is not permitted');
157+
}
158+
159+
const { pathToFile } = req.body;
160+
if (!pathToFile)
161+
return res.status(400).send("'pathToFile' is missing. Please provide.");
162+
163+
if (path.extname(pathToFile) !== '.sql')
164+
return res
165+
.status(400)
166+
.send("The provided path is not in the expected '.sql' format.");
167+
168+
// Backup current db before running restore in case there is a need to revert
169+
const dumpFileName = `${
170+
process.env.ENVIRONMENT
171+
}_dump_${dates.convertDateToString(
172+
new Date(),
173+
'YYYYMMDD_HHmmss'
174+
)}_before_restore.sql`;
175+
176+
try {
177+
const result = await dumpPostgresDatabaseToFile(dumpFileName);
178+
res.status(200).write(`${result}\n\n`);
179+
} catch (error) {
180+
return res
181+
.status(400)
182+
.send(
183+
`Unable to continue restore. Failed to backup current data:\n${error.message}`
184+
);
185+
}
186+
187+
// Purge the database's tables and functions to make restoring with the
188+
// pg import easier
189+
await removeDatabaseTablesAndFunctions(res);
190+
191+
try {
192+
const pgImportCommand = `PGPASSWORD=${DB_PASSWORD} psql -U ${DB_USER} -h ${DB_HOST} -d ${DB_NAME} -f ${pathToFile}`;
193+
194+
// Execute the command
195+
exec(pgImportCommand, (error, stdout, stderr) => {
196+
if (error) {
197+
res
198+
.status(400)
199+
.write(`Error executing pg import: ${sanitizeError(error.message)}`);
200+
}
201+
if (stderr) {
202+
res.status(400).write(`pg import stderr:: ${sanitizeError(stderr)}`);
203+
}
204+
res.status(200).write(`Database import completed successfully`);
205+
return res.end();
206+
});
207+
} catch (error) {
208+
res
209+
.status(400)
210+
.write(`Unable to import database: ${sanitizeError(error.message)}`);
211+
return res.end();
212+
}
213+
};
214+
215+
const cleanFolder = (req, res) => {
216+
const { maxFiles } = req.body;
217+
218+
if (maxFiles) {
219+
if (!isNaN(maxFiles) && Number.isInteger(Number(maxFiles))) {
220+
// continue
221+
} else {
222+
return res
223+
.status(400)
224+
.send("Unable to parse the 'maxFiles' value provided.");
225+
}
226+
} else {
227+
// continue
228+
}
229+
230+
const CLEAN_DIR = path.join(os.homedir(), 'db_dumps');
231+
// Default the number of max files to 28 if no value provided
232+
const MAX_FILES = Number(maxFiles) || 28;
233+
234+
if (
235+
process.env.ENVIRONMENT === 'production' ||
236+
process.env.API_SERVER === 'https://aria-at.w3.org' ||
237+
req.hostname.includes('aria-at.w3.org')
238+
) {
239+
if (!CLEAN_DIR.includes('/home/aria-bot/db_dumps')) {
240+
return res
241+
.status(500)
242+
.send("Please ensure the 'db_dumps' folder is properly set.");
243+
}
244+
}
245+
246+
try {
247+
// Read all files in the folder
248+
const files = fs.readdirSync(CLEAN_DIR).map(file => {
249+
const filePath = path.join(CLEAN_DIR, file);
250+
return {
251+
name: file,
252+
path: filePath,
253+
time: fs.statSync(filePath).mtime.getTime()
254+
};
255+
});
256+
257+
files.sort((a, b) => a.time - b.time);
258+
259+
// Delete files if there are more than maxFiles
260+
if (files.length > MAX_FILES) {
261+
const removedFiles = [];
262+
263+
const filesToDelete = files.slice(0, files.length - MAX_FILES);
264+
filesToDelete.forEach(file => {
265+
fs.unlinkSync(file.path);
266+
removedFiles.push(file);
267+
});
268+
return res
269+
.status(200)
270+
.send(
271+
`Removed the following files:\n${removedFiles
272+
.map(({ name }, index) => `#${index + 1}) ${name}`)
273+
.join('\n')}`
274+
);
275+
} else {
276+
return res
277+
.status(200)
278+
.send('No files to delete. Folder is within the limit.');
279+
}
280+
} catch (error) {
281+
return res.status(400).send(`Error cleaning folder: ${error.message}`);
282+
}
283+
};
284+
285+
module.exports = {
286+
dumpPostgresDatabase,
287+
restorePostgresDatabase,
288+
cleanFolder
289+
};

server/routes/database.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const { Router } = require('express');
2+
const {
3+
dumpPostgresDatabase,
4+
restorePostgresDatabase,
5+
cleanFolder
6+
} = require('../controllers/DatabaseController');
7+
8+
const router = Router();
9+
10+
router.post('/dump', dumpPostgresDatabase);
11+
router.post('/restore', restorePostgresDatabase);
12+
router.post('/cleanFolder', cleanFolder);
13+
14+
module.exports = router;

0 commit comments

Comments
 (0)