@stamhoofd/backend-backup 2.50.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.template.json +4 -0
- package/LICENSE +665 -0
- package/README.md +3 -0
- package/eslint.config.mjs +5 -0
- package/index.ts +108 -0
- package/package.json +39 -0
- package/src/crons.ts +50 -0
- package/src/endpoints/BackupEndpoint.ts +59 -0
- package/src/endpoints/HealthEndpoint.ts +83 -0
- package/src/helpers/backup.ts +640 -0
- package/src/helpers/replica-status.ts +42 -0
- package/stamhoofd.d.ts +14 -0
- package/tsconfig.json +34 -0
package/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import backendEnv from '@stamhoofd/backend-env';
|
|
2
|
+
backendEnv.load({ service: 'backup' });
|
|
3
|
+
|
|
4
|
+
import { CORSPreflightEndpoint, Router, RouterServer } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { CORSMiddleware, LogMiddleware } from '@stamhoofd/backend-middleware';
|
|
6
|
+
import { loadLogger } from '@stamhoofd/logging';
|
|
7
|
+
import { startCrons, stopCrons, waitForCrons } from '@stamhoofd/crons';
|
|
8
|
+
import { cleanBackups } from './src/helpers/backup';
|
|
9
|
+
|
|
10
|
+
process.on('unhandledRejection', (error: Error) => {
|
|
11
|
+
console.error('unhandledRejection');
|
|
12
|
+
console.error(error.message, error.stack);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Set timezone!
|
|
17
|
+
process.env.TZ = 'UTC';
|
|
18
|
+
|
|
19
|
+
// Quick check
|
|
20
|
+
if (new Date().getTimezoneOffset() !== 0) {
|
|
21
|
+
throw new Error('Process should always run in UTC timezone');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const start = async () => {
|
|
25
|
+
console.log('Started Backup.');
|
|
26
|
+
loadLogger();
|
|
27
|
+
const router = new Router();
|
|
28
|
+
await router.loadEndpoints(__dirname + '/src/endpoints');
|
|
29
|
+
router.endpoints.push(new CORSPreflightEndpoint());
|
|
30
|
+
|
|
31
|
+
const routerServer = new RouterServer(router);
|
|
32
|
+
routerServer.verbose = false;
|
|
33
|
+
|
|
34
|
+
// Send the app version along
|
|
35
|
+
routerServer.addRequestMiddleware(LogMiddleware);
|
|
36
|
+
routerServer.addResponseMiddleware(LogMiddleware);
|
|
37
|
+
|
|
38
|
+
// Add CORS headers
|
|
39
|
+
routerServer.addResponseMiddleware(CORSMiddleware);
|
|
40
|
+
|
|
41
|
+
routerServer.listen(STAMHOOFD.PORT ?? 9090);
|
|
42
|
+
|
|
43
|
+
if (routerServer.server) {
|
|
44
|
+
// Default timeout is a bit too short
|
|
45
|
+
routerServer.server.timeout = 15000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const shutdown = async () => {
|
|
49
|
+
console.log('Shutting down...');
|
|
50
|
+
// Disable keep alive
|
|
51
|
+
routerServer.defaultHeaders = Object.assign(routerServer.defaultHeaders, { Connection: 'close' });
|
|
52
|
+
if (routerServer.server) {
|
|
53
|
+
routerServer.server.headersTimeout = 5000;
|
|
54
|
+
routerServer.server.keepAliveTimeout = 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stopCrons();
|
|
58
|
+
|
|
59
|
+
if (STAMHOOFD.environment === 'development') {
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
console.error('Forcing exit after 5 seconds');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}, 5000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await routerServer.close();
|
|
68
|
+
console.log('HTTP server stopped');
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error('Failed to stop HTTP server:');
|
|
72
|
+
console.error(err);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await waitForCrons();
|
|
76
|
+
|
|
77
|
+
// Should not be needed, but added for security as sometimes a promise hangs somewhere
|
|
78
|
+
process.exit(0);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
process.on('SIGTERM', () => {
|
|
82
|
+
console.info('SIGTERM signal received.');
|
|
83
|
+
shutdown().catch((e) => {
|
|
84
|
+
console.error(e);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
process.on('SIGINT', () => {
|
|
90
|
+
console.info('SIGINT signal received.');
|
|
91
|
+
shutdown().catch((e) => {
|
|
92
|
+
console.error(e);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Register crons
|
|
98
|
+
await import('./src/crons');
|
|
99
|
+
startCrons();
|
|
100
|
+
|
|
101
|
+
// Clean backups on boot (bit faster to retrieve the timestamp of the last backup for the health endpoint)
|
|
102
|
+
await cleanBackups();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
start().catch((error) => {
|
|
106
|
+
console.error('unhandledRejection', error);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stamhoofd/backend-backup",
|
|
3
|
+
"version": "2.50.2",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"require": "./dist/index.js"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"license": "UNLICENCED",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "echo 'Waiting for shared backend packages...' && wait-on ../../shared/middleware/dist/index.js && echo 'Start building backend backup server' && concurrently -r 'yarn build --watch --preserveWatchOutput' \"wait-on ./dist/index.js && nodemon --quiet --inspect=5858 --watch dist --watch '../../../shared/*/dist/' --watch '../../shared/*/dist/' --ext .ts,.json,.sql,.js --watch .env.json --delay 1000ms --exec 'node --enable-source-maps ./dist/index.js' --signal SIGTERM\"",
|
|
13
|
+
"build": "tsc -b",
|
|
14
|
+
"build:full": "yarn clear && yarn build",
|
|
15
|
+
"clear": "rm -rf ./dist",
|
|
16
|
+
"start": "yarn build && node --enable-source-maps ./dist/index.js",
|
|
17
|
+
"lint": "eslint"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/formidable": "3.4.5",
|
|
21
|
+
"@types/luxon": "3.4.2",
|
|
22
|
+
"@types/mysql": "^2.15.20",
|
|
23
|
+
"@types/node": "^20.12"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@aws-sdk/client-s3": "^3.678.0",
|
|
27
|
+
"@simonbackx/simple-endpoints": "1.14.0",
|
|
28
|
+
"@simonbackx/simple-logging": "^1.0.1",
|
|
29
|
+
"formidable": "3.5.1",
|
|
30
|
+
"luxon": "3.4.4",
|
|
31
|
+
"mockdate": "^3.0.2",
|
|
32
|
+
"mysql": "^2.18.1",
|
|
33
|
+
"puppeteer": "22.12.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"gitHead": "ffbc162ba4d0def7714d88b255b72a5f8991b4c3"
|
|
39
|
+
}
|
package/src/crons.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
+
import { backup, backupBinlogs, cleanBackups, cleanBinaryLogBackups } from './helpers/backup';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
|
|
5
|
+
let lastFullBackup: Date | null = null;
|
|
6
|
+
const backupStartTime = '02:00';
|
|
7
|
+
const backupEndTime = '05:00';
|
|
8
|
+
|
|
9
|
+
async function createBackups() {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
|
|
12
|
+
if (lastFullBackup && Formatter.date(now, true) === Formatter.date(lastFullBackup, true)) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Check time
|
|
17
|
+
if (Formatter.timeIso(now) < backupStartTime || Formatter.timeIso(now) > backupEndTime) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log('Creating full backup...');
|
|
22
|
+
await backup();
|
|
23
|
+
|
|
24
|
+
console.log('Full backup created');
|
|
25
|
+
lastFullBackup = now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function backupBinLogs() {
|
|
29
|
+
console.log('Backing up binlogs...');
|
|
30
|
+
await backupBinlogs();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let lastClean = new Date(0); // on boot
|
|
34
|
+
const cleanInterval = 1000 * 60 * 60 * 4; // every 4 hours
|
|
35
|
+
|
|
36
|
+
async function clean() {
|
|
37
|
+
const now = new Date();
|
|
38
|
+
|
|
39
|
+
if (now.getTime() - lastClean.getTime() > cleanInterval) {
|
|
40
|
+
console.log('Cleaning backups...');
|
|
41
|
+
await cleanBackups();
|
|
42
|
+
await cleanBinaryLogBackups();
|
|
43
|
+
lastClean = now;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
registerCron('createBackups', createBackups);
|
|
48
|
+
registerCron('backupBinLogs', backupBinLogs);
|
|
49
|
+
|
|
50
|
+
registerCron('clean', clean);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
+
import { backup } from '../helpers/backup';
|
|
5
|
+
|
|
6
|
+
type Params = Record<string, never>;
|
|
7
|
+
type Body = undefined;
|
|
8
|
+
|
|
9
|
+
class Query extends AutoEncoder {
|
|
10
|
+
@field({ decoder: StringDecoder, optional: true })
|
|
11
|
+
key?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ResponseBody = string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class BackupEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
21
|
+
queryDecoder = Query;
|
|
22
|
+
|
|
23
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
24
|
+
if (request.method !== 'POST') {
|
|
25
|
+
return [false];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const params = Endpoint.parseParameters(request.url, '/backup', {});
|
|
29
|
+
|
|
30
|
+
if (params) {
|
|
31
|
+
return [true, params as Params];
|
|
32
|
+
}
|
|
33
|
+
return [false];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
37
|
+
if (!STAMHOOFD.HEALTH_ACCESS_KEY) {
|
|
38
|
+
throw new SimpleError({
|
|
39
|
+
code: 'unauthorized',
|
|
40
|
+
message: 'Unauthorized',
|
|
41
|
+
statusCode: 401,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (STAMHOOFD.HEALTH_ACCESS_KEY && request.query.key !== STAMHOOFD.HEALTH_ACCESS_KEY) {
|
|
46
|
+
throw new SimpleError({
|
|
47
|
+
code: 'unauthorized',
|
|
48
|
+
message: 'Unauthorized',
|
|
49
|
+
statusCode: 401,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
backup().catch(console.error);
|
|
54
|
+
|
|
55
|
+
const response = new Response('Scheduled backup');
|
|
56
|
+
response.status = 201;
|
|
57
|
+
return response;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { BackupHealth, getHealth } from '../helpers/backup';
|
|
3
|
+
import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
|
|
4
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
|
+
import { checkReplicaStatus } from '../helpers/replica-status';
|
|
6
|
+
|
|
7
|
+
type Params = Record<string, never>;
|
|
8
|
+
type Body = undefined;
|
|
9
|
+
|
|
10
|
+
class Query extends AutoEncoder {
|
|
11
|
+
@field({ decoder: StringDecoder, optional: true })
|
|
12
|
+
key?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class BackupWithReplicationHealth extends BackupHealth {
|
|
16
|
+
@field({ decoder: StringDecoder, nullable: true })
|
|
17
|
+
replicationError: string | null = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ResponseBody = BackupWithReplicationHealth;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export class HealthEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
27
|
+
queryDecoder = Query;
|
|
28
|
+
|
|
29
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
30
|
+
if (request.method !== 'GET') {
|
|
31
|
+
return [false];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const params = Endpoint.parseParameters(request.url, '/health', {});
|
|
35
|
+
|
|
36
|
+
if (params) {
|
|
37
|
+
return [true, params as Params];
|
|
38
|
+
}
|
|
39
|
+
return [false];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
43
|
+
if (!STAMHOOFD.HEALTH_ACCESS_KEY) {
|
|
44
|
+
throw new SimpleError({
|
|
45
|
+
code: 'unauthorized',
|
|
46
|
+
message: 'Unauthorized',
|
|
47
|
+
statusCode: 401,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (STAMHOOFD.HEALTH_ACCESS_KEY && request.query.key !== STAMHOOFD.HEALTH_ACCESS_KEY) {
|
|
52
|
+
throw new SimpleError({
|
|
53
|
+
code: 'unauthorized',
|
|
54
|
+
message: 'Unauthorized',
|
|
55
|
+
statusCode: 401,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baseHealth = getHealth();
|
|
60
|
+
const health = BackupWithReplicationHealth.create({
|
|
61
|
+
...baseHealth,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (STAMHOOFD.IS_REPLICA) {
|
|
65
|
+
try {
|
|
66
|
+
await checkReplicaStatus();
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
health.replicationError = e.message;
|
|
70
|
+
health.status = 'error';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const response = new Response(
|
|
75
|
+
health,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (health.status === 'error') {
|
|
79
|
+
response.status = 503;
|
|
80
|
+
}
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
}
|