@tmlmobilidade/backupd 20251229.1441.35

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/Dockerfile ADDED
@@ -0,0 +1,55 @@
1
+ # # # # # # # # #
2
+
3
+ FROM node:alpine AS base
4
+
5
+ ENV APP=backupd
6
+
7
+ RUN npm install -g turbo@^2
8
+
9
+ # Install MongoDB
10
+ RUN echo 'http://dl-cdn.alpinelinux.org/alpine/v3.6/main' >> /etc/apk/repositories
11
+ RUN echo 'http://dl-cdn.alpinelinux.org/alpine/v3.6/community' >> /etc/apk/repositories
12
+ RUN apk update
13
+ RUN apk add mongodb-tools
14
+
15
+ # # # # # # # # #
16
+
17
+
18
+ # # #
19
+ # BUILDER STAGE
20
+
21
+ FROM base AS pruner
22
+
23
+ WORKDIR /app
24
+
25
+ COPY . .
26
+
27
+ RUN turbo prune --scope=@tmlmobilidade/${APP} --docker
28
+
29
+
30
+ # # #
31
+ # INSTALLER STAGE
32
+
33
+ FROM base AS builder
34
+
35
+ WORKDIR /app
36
+
37
+ # First install the dependencies (as they change less often)
38
+ COPY --from=pruner /app/out/json/ .
39
+ RUN npm install
40
+
41
+ # Build the app
42
+ COPY --from=pruner /app/out/full/ .
43
+ RUN turbo run build --filter=@tmlmobilidade/${APP}
44
+
45
+
46
+ # # #
47
+ # RUNNER STAGE
48
+
49
+ FROM base AS runner
50
+
51
+ WORKDIR /app
52
+
53
+ COPY --from=builder /app .
54
+
55
+ CMD node configs/${APP}/dist/index.js
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ ## Overview
2
+
3
+ The service will run continuously, performing backups at the interval specified in the configuration file.
4
+
5
+ ## Setup
6
+
7
+ This service uses a configuration file in YAML format. There is an example file in the repository (`config.example.yaml`).
8
+
9
+ In addition to the configuration file, this service uses the environment variable `BACKUPD_CONFIG_PATH` to locate the configuration file.
10
+
11
+ ## Configuration File Template
12
+
13
+ The configuration file includes **storage**, **database**,**backup schedule**, and optional **email notification** settings. This file enables you to specify the exact setup for these operations.
14
+
15
+ ### Structure of `config.yaml`
16
+
17
+ - **Storage Configuration**
18
+ - **Database Configuration**
19
+ - **Backup Configuration**
20
+ - **Email Configuration**
21
+
22
+ ---
23
+
24
+ ### Configuration Parameters
25
+
26
+ #### 1. Storage Configuration
27
+ Defines the storage service for backups.
28
+
29
+ ```yaml
30
+ storage:
31
+ type: STORAGE_TYPE
32
+ aws_config:
33
+ aws_access_key_id: YOUR_AWS_ACCESS_KEY_ID
34
+ aws_secret_access_key: YOUR_AWS_SECRET_ACCESS_KEY
35
+ bucket_name: YOUR_BUCKET_NAME
36
+ region: YOUR_REGION
37
+ oci_config:
38
+ tenancy: YOUR_TENANCY
39
+ user: YOUR_USER
40
+ fingerprint: YOUR_FINGERPRINT
41
+ private_key_path: YOUR_PRIVATE_KEY_PATH
42
+ ```
43
+
44
+ - `type`: Set to `"aws"` for AWS S3 or `"oci"` for Oracle Cloud Storage.
45
+ - **AWS Config**: (Required if `type` is `"aws"`)
46
+ - `aws_access_key_id`, `aws_secret_access_key`: AWS credentials.
47
+ - `bucket_name`, `region`: Define the S3 bucket and region.
48
+ - **OCI Config**: (Required if `type` is `"oci"`)
49
+ - `tenancy`, `user`, `fingerprint`, `private_key_path`: Required fields for Oracle Cloud access.
50
+
51
+ #### 2. Database Configuration
52
+ Specifies the database type and connection parameters.
53
+
54
+ ```yaml
55
+ database:
56
+ type: DATABASE_TYPE
57
+ mongodb_config:
58
+ uri: YOUR_MONGODB_URI
59
+ options:
60
+ connectTimeoutMS: 10000
61
+ ...
62
+ postgres_config:
63
+ uri: YOUR_POSTGRES_URI
64
+ options:
65
+ max: 10
66
+ min: 5
67
+ ...
68
+ ```
69
+
70
+ - `type`: Database service to use (`mongodb` or `postgres`).
71
+ - **MongoDB Config**: (Required if `type` is `"mongodb"`)
72
+ - `uri`: MongoDB connection URI.
73
+ - `options`: Optional connection parameters (timeout, pooling, and read preferences).
74
+ - **PostgreSQL Config**: (Required if `type` is `"postgres"`)
75
+ - `uri`: PostgreSQL connection URI.
76
+ - `options`: Pooling options.
77
+
78
+ #### 3. Backup Configuration
79
+ Manages backup intervals, destinations, and retention.
80
+
81
+ ```yaml
82
+ backup:
83
+ interval: 360
84
+ destination: backup/PROJECT_NAME/
85
+ max_remote_backups: 10
86
+ max_local_backups: 10
87
+ ```
88
+
89
+ - `interval`: Frequency of backups in minutes (e.g., `360` for every 6 hours).
90
+ - `destination`: Local directory path for storing backups.
91
+ - `max_remote_backups`: Set retention limits for remote backups. (If set to 0, there will be no limit stored in the remote storage.)
92
+ - `max_local_backups`: Set retention limits for local backups. (If set to 0, all local backups will be deleted.)
93
+
94
+ #### 4. Email Configuration (Optional)
95
+ Enables email notifications upon backup success or failure.
96
+
97
+ ```yaml
98
+ email:
99
+ send_success: true
100
+ send_failure: true
101
+ mail_options:
102
+ from: FROM_EMAIL_ADDRESS
103
+ to: TO_EMAIL_ADDRESS
104
+ subject: BACKUP_REPORT_SUBJECT
105
+ smtp:
106
+ host: YOUR_SMTP_HOST
107
+ port: YOUR_SMTP_PORT
108
+ auth:
109
+ user: YOUR_SMTP_USER
110
+ pass: YOUR_SMTP_PASSWORD
111
+ ```
112
+
113
+ - `send_success`, `send_failure`: Set to `true` to enable email notifications.
114
+ - `mail_options`: Sender and recipient details.
115
+ - `smtp`: SMTP server configuration for outgoing emails.
116
+
117
+ ---
118
+
119
+ ### Usage
120
+ 1. Copy this template file as `config.yaml`.
121
+ 2. Replace the placeholder values with your actual configurations.
122
+ 3. Save and start the `backupd` service.
123
+
124
+ This file enables `backupd` to seamlessly back up and monitor your databases across different storage services.
125
+
@@ -0,0 +1,106 @@
1
+ # This is an example configuration file for backupd.
2
+ # It is not used by the application, but it is a template for the configuration file needed to run the backupd service
3
+
4
+ # The configuration file is a YAML file, which is a human-readable data serialization format.
5
+ # It is used to configure the backupd service.
6
+
7
+ # The Storage Configuration
8
+ storage:
9
+ # The type of storage service to use. Options: "aws" (AWS) | "oci" (Oracle Cloud Infrastructure Object Storage)
10
+ type: STORAGE_TYPE
11
+
12
+ # The AWS configuration
13
+ aws_config:
14
+ aws_access_key_id: YOUR_AWS_ACCESS_KEY_ID
15
+ aws_secret_access_key: YOUR_AWS_SECRET_ACCESS_KEY
16
+ bucket_name: YOUR_BUCKET_NAME
17
+ region: YOUR_REGION
18
+
19
+ # The Oracle Cloud Infrastructure Object Storage configuration
20
+ oci_config:
21
+ user: OCI_USER
22
+ fingerprint: OCI_FINGERPRINT
23
+ tenancy: OCI_TENANCY
24
+ region: OCI_REGION
25
+ private_key: OCI_PRIVATE_KEY # The private key of the user in PEM format
26
+ namespace: OCI_NAMESPACE
27
+ bucket_name: OCI_BUCKET_NAME
28
+
29
+ # The Cloudflare R2 configuration
30
+ r2_config:
31
+ account_id: YOUR_ACCOUNT_ID
32
+ access_key_id: YOUR_ACCESS_KEY_ID
33
+ secret_access_key: YOUR_SECRET_ACCESS_KEY
34
+ bucket_name: YOUR_BUCKET_NAME
35
+ endpoint: YOUR_ENDPOINT # https://<account_id>.r2.cloudflarestorage.com
36
+
37
+ # The Database Configuration
38
+ database:
39
+ # The type of database service to use. Options: "mongodb" (MongoDB) | "postgres" (PostgreSQL)
40
+ type: DATABASE_TYPE
41
+
42
+ # The MongoDB configuration (required if type is mongodb)
43
+ mongodb_config:
44
+ uri: YOUR_MONGODB_URI
45
+ options: # (Optional) The options to pass to the MongoDB client.
46
+ # The time in milliseconds to attempt a connection before timing out.
47
+ connectTimeoutMS: 10000
48
+ # Whether to use direct connection to the MongoDB server.
49
+ directConnection: true
50
+ # The maximum number of connections in the connection pool.
51
+ maxPoolSize: 10
52
+ # The minimum number of connections in the connection pool.
53
+ minPoolSize: 5
54
+ # The read preference to use. Options: "primary" | "primaryPreferred" | "secondary" | "secondaryPreferred" | "nearest"
55
+ readPreference: YOUR_READ_PREFERENCE
56
+ # The time in milliseconds to wait for a server to respond to a request before timing out.
57
+ serverSelectionTimeoutMS: 10000
58
+ # (Optional) The MongoDB dump options.
59
+ dump_options:
60
+ # (Optional) The database name to dump.
61
+ database: YOUR_DATABASE_NAME
62
+ # (Optional) The collections to exclude from the dump.
63
+ exclude_collections:
64
+ - YOUR_COLLECTION_NAME
65
+ - YOUR_COLLECTION_NAME
66
+
67
+ # The PostgreSQL configuration (required if type is postgres)
68
+ postgres_config:
69
+ uri: YOUR_POSTGRES_URI
70
+ options: # (Optional) The options to pass to the PostgreSQL client.
71
+ # The maximum number of connections in the connection pool.
72
+ max: 10
73
+ # The minimum number of connections in the connection pool.
74
+ min: 5
75
+
76
+ # The Backup Configuration
77
+ backup:
78
+ # The interval between backups in minutes.
79
+ interval: 360 # 6 hour
80
+ # Backup destination directory.
81
+ destination: PATH_TO_BACKUP_DESTINATION_DIRECTORY
82
+ # The remote destination directory.
83
+ remote_destination: PATH_TO_REMOTE_DESTINATION_DIRECTORY
84
+ # The maximum number of backups to keep. (If undefined or 0, no backups will be deleted.)
85
+ max_remote_backups: 10
86
+ # The maximum number of backup files to keep in the storage. (If set to 0, no backups will be stored in the device storage.)
87
+ max_local_backups: 10
88
+
89
+ # The Email Configuration (if not set, no email will be sent)
90
+ email:
91
+ # Whether to send the backup success report.
92
+ send_success: true
93
+ # Whether to send the backup failure report.
94
+ send_failure: true
95
+ # The mail options.
96
+ mail_options:
97
+ from: FROM_EMAIL_ADDRESS
98
+ to: TO_EMAIL_ADDRESS
99
+ subject: BACKUP_REPORT_SUBJECT
100
+ # The SMTP server configuration.
101
+ smtp:
102
+ host: YOUR_SMTP_HOST
103
+ port: YOUR_SMTP_PORT
104
+ auth:
105
+ user: YOUR_SMTP_USER
106
+ pass: YOUR_SMTP_PASSWORD
@@ -0,0 +1,14 @@
1
+ /* * */
2
+
3
+ import { node } from '@carrismetropolitana/eslint'
4
+
5
+ /* * */
6
+
7
+ export default [
8
+ {
9
+ rules: {
10
+ 'no-extraneous-class': 'off',
11
+ },
12
+ },
13
+ ...node,
14
+ ]
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@tmlmobilidade/backupd",
3
+ "description": "A backup service for databases.",
4
+ "version": "20251229.1441.35",
5
+ "author": {
6
+ "email": "iso@tmlmobilidade.pt",
7
+ "name": "TML-ISO"
8
+ },
9
+ "license": "AGPL-3.0-or-later",
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "scripts": {
13
+ "build": "tsc && resolve-tspaths",
14
+ "lint": "eslint ./src && tsc --noEmit",
15
+ "start": "npm run build && node dist/index.js"
16
+ },
17
+ "dependencies": {
18
+ "@tmlmobilidade/consts": "*",
19
+ "@tmlmobilidade/dates": "*",
20
+ "@tmlmobilidade/emails": "*",
21
+ "@tmlmobilidade/interfaces": "*",
22
+ "@tmlmobilidade/logger": "*",
23
+ "@tmlmobilidade/types": "*",
24
+ "@tmlmobilidade/utils": "*",
25
+ "@types/archiver": "7.0.0",
26
+ "archiver": "7.0.1",
27
+ "mongodb": "7.0.0",
28
+ "nodemailer": "7.0.12",
29
+ "pg": "8.16.3",
30
+ "react": "19.2.3",
31
+ "resolve-tspaths": "0.8.23",
32
+ "yaml": "2.8.2"
33
+ },
34
+ "devDependencies": {
35
+ "@tmlmobilidade/tsconfig": "*",
36
+ "@types/node": "25.0.3",
37
+ "@types/nodemailer": "7.0.4",
38
+ "typescript": "5.9.3"
39
+ }
40
+ }
@@ -0,0 +1,84 @@
1
+ import { IDatabaseService } from '@/database/database.interface.js';
2
+ import { IStorageProvider } from '@tmlmobilidade/interfaces';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ export interface BackupConfig {
7
+ destination: string
8
+ interval: number
9
+ max_local_backups: number
10
+ max_remote_backups: number
11
+ remote_destination: string
12
+ }
13
+
14
+ export class BackupService {
15
+ private readonly config: BackupConfig;
16
+ private readonly databaseService: IDatabaseService;
17
+ private readonly storageService: IStorageProvider;
18
+
19
+ constructor(config: BackupConfig, databaseService: IDatabaseService, storageService: IStorageProvider) {
20
+ this.config = config;
21
+ this.databaseService = databaseService;
22
+ this.storageService = storageService;
23
+ }
24
+
25
+ /**
26
+ * Perform a backup of the database and upload it to the storage.
27
+ */
28
+ public async backup(): Promise<void> {
29
+ // Create a backup directory with the current timestamp
30
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
31
+ const backupDir = path.join(this.config.destination, timestamp);
32
+ fs.mkdirSync(backupDir, { recursive: true });
33
+
34
+ // Perform backup
35
+ await this.databaseService.backup(backupDir);
36
+
37
+ // Upload backup to storage
38
+ const backupFile = fs.readFileSync(backupDir + '.zip');
39
+ await this.storageService.uploadFile(path.join(this.config.remote_destination, timestamp + '.zip'), backupFile);
40
+
41
+ // Delete old backups
42
+ console.log('Deleting old backups...', {
43
+ max_local_backups: this.config.max_local_backups,
44
+ max_remote_backups: this.config.max_remote_backups,
45
+ });
46
+ if (this.config.max_local_backups > 0) {
47
+ await this.deleteLocalBackups();
48
+ }
49
+ else if (this.config.max_local_backups === 0) { // Delete all local backups
50
+ fs.rmSync(this.config.destination, { recursive: true });
51
+ }
52
+
53
+ if (this.config.max_remote_backups > 0) {
54
+ await this.deleteRemoteBackups();
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Delete old backups from the local storage.
60
+ */
61
+ private async deleteLocalBackups(): Promise<void> {
62
+ // Delete old local backups
63
+ const localBackups = fs.readdirSync(this.config.destination).sort();
64
+ if (localBackups.length > this.config.max_local_backups) {
65
+ // Delete oldest local backups first
66
+ const localBackupsToDelete = localBackups.slice(0, localBackups.length - this.config.max_local_backups);
67
+ for (const backup of localBackupsToDelete) {
68
+ fs.rmSync(path.join(this.config.destination, backup), { recursive: true });
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Delete old backups from the local storage.
75
+ */
76
+ private async deleteRemoteBackups(): Promise<void> {
77
+ const backups = await this.storageService.listFiles(this.config.remote_destination);
78
+ backups.sort(); // Sort by timestamp since they're ISO format strings
79
+ if (backups.length > this.config.max_remote_backups) {
80
+ const backupsToDelete = backups.slice(0, backups.length - this.config.max_remote_backups);
81
+ await Promise.all(backupsToDelete.map(backup => this.storageService.deleteFile(backup)));
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,168 @@
1
+ // configLoader.ts
2
+ import * as fs from 'fs';
3
+ import * as yaml from 'yaml';
4
+
5
+ import { AppConfig } from './config-types.js';
6
+
7
+ function validateConfig(config: AppConfig) {
8
+ //
9
+
10
+ //
11
+ // Validate storage configuration
12
+ if (!config.storage) {
13
+ throw new Error('Missing required field \'storage\' configuration.');
14
+ }
15
+
16
+ if (config.storage.type !== 'oci') {
17
+ throw new Error('Invalid storage type. Supported types are \'oci\'.');
18
+ }
19
+
20
+ if (config.storage.type === 'oci') {
21
+ if (!config.storage.oci_config) {
22
+ throw new Error('Storage type is \'oci\' but \'oci_config\' is missing.');
23
+ }
24
+
25
+ const keys_missing: string[] = [];
26
+ for (const key of ['user', 'fingerprint', 'tenancy', 'region', 'private_key', 'namespace', 'bucket_name']) {
27
+ if (!config.storage.oci_config[key]) {
28
+ keys_missing.push(key);
29
+ }
30
+ }
31
+ if (keys_missing.length > 0) {
32
+ throw new Error(`Missing required fields in 'oci_config'. Ensure ${keys_missing.join(', ')} are set.`);
33
+ }
34
+ }
35
+
36
+ //
37
+ // Validate database configuration
38
+ if (!config.database) {
39
+ throw new Error('Missing required field \'database\' configuration.');
40
+ }
41
+
42
+ if (config.database.type !== 'mongodb' && config.database.type !== 'postgres') {
43
+ throw new Error('Invalid database type. Supported types are \'mongodb\' and \'postgres\'.');
44
+ }
45
+
46
+ if (config.database.type === 'mongodb') {
47
+ if (!config.database.mongodb_config) {
48
+ throw new Error('Database type is \'mongodb\' but \'mongodb_config\' is missing.');
49
+ }
50
+ if (!config.database.mongodb_config.uri) {
51
+ throw new Error('Missing required field \'uri\' in \'mongodb_config\'.');
52
+ }
53
+ }
54
+ else if (config.database.type === 'postgres') {
55
+ if (!config.database.postgres_config) {
56
+ throw new Error('Database type is \'postgres\' but \'postgres_config\' is missing.');
57
+ }
58
+ if (!config.database.postgres_config.uri) {
59
+ throw new Error('Missing required field \'uri\' in \'postgres_config\'.');
60
+ }
61
+ }
62
+
63
+ //
64
+ // Validate Backup configuration
65
+ if (!config.backup) {
66
+ throw new Error('Missing required field \'backup\' configuration.');
67
+ }
68
+
69
+ if (!config.backup.interval) {
70
+ throw new Error('Missing required field \'interval\' in \'backup\' configuration.');
71
+ }
72
+ else if (config.backup.interval <= 0 || typeof config.backup.interval !== 'number') {
73
+ throw new Error('\'interval\' must be a number greater than 0 in \'backup\' configuration.');
74
+ }
75
+
76
+ if (!config.backup.destination) {
77
+ throw new Error('Missing required field \'destination\' in \'backup\' configuration.');
78
+ }
79
+
80
+ if (!config.backup.max_local_backups) {
81
+ console.warn('\'max_local_backups\' is not set in \'backup\' configuration. No backups will be deleted.');
82
+ }
83
+ else if (config.backup.max_local_backups <= 0 || typeof config.backup.max_local_backups !== 'number') {
84
+ throw new Error('\'max_local_backups\' must be a number greater than 0 in \'backup\' configuration.');
85
+ }
86
+
87
+ if (!config.backup.max_remote_backups) {
88
+ console.warn('\'max_remote_backups\' is not set in \'backup\' configuration. No backups will be stored in the device storage.');
89
+ }
90
+ else if (config.backup.max_remote_backups <= 0 || typeof config.backup.max_remote_backups !== 'number') {
91
+ throw new Error('\'max_remote_backups\' must be a number greater than 0 in \'backup\' configuration.');
92
+ }
93
+ else if (!config.backup.remote_destination) {
94
+ throw new Error('\'remote_destination\' is not set in \'backup\' configuration.');
95
+ }
96
+
97
+ //
98
+ // Validate Email configuration
99
+ if (!config.email) {
100
+ console.warn('\'email\' configuration is not set. No email will be sent.');
101
+ }
102
+ else {
103
+ const { mail_options, send_failure, send_success, smtp } = config.email;
104
+ if (typeof send_success !== 'boolean') {
105
+ throw new Error('\'email.send_success\' should be a boolean.');
106
+ }
107
+ if (typeof send_failure !== 'boolean') {
108
+ throw new Error('\'email.send_failure\' should be a boolean.');
109
+ }
110
+
111
+ if (!mail_options) {
112
+ throw new Error('Missing \'mail_options\' in email configuration.');
113
+ }
114
+ else {
115
+ if (!mail_options.from || typeof mail_options.from !== 'string') {
116
+ throw new Error('\'from\' in \'mail_options\' configuration should be a string.');
117
+ }
118
+ if (!mail_options.subject || typeof mail_options.subject !== 'string') {
119
+ throw new Error('\'subject\' in \'mail_options\' configuration should be a string.');
120
+ }
121
+ if (!mail_options.to || (typeof mail_options.to !== 'string' && !Array.isArray(mail_options.to))) {
122
+ throw new Error('\'to\' in \'mail_options\' configuration should be a string or an array of strings.');
123
+ }
124
+ }
125
+
126
+ if (!smtp) {
127
+ throw new Error('Missing \'smtp\' configuration in email.');
128
+ }
129
+ else {
130
+ const { host, port } = smtp;
131
+ if (!host) {
132
+ throw new Error('Missing \'host\' in \'smtp\' configuration.');
133
+ }
134
+ if (typeof port !== 'number' || port <= 0) {
135
+ throw new Error('\'smtp.port\' should be a positive number.');
136
+ }
137
+ if (!smtp.auth.user) {
138
+ throw new Error('Missing \'user\' in \'smtp.auth\' configuration.');
139
+ }
140
+ if (!smtp.auth.pass) {
141
+ throw new Error('Missing \'password\' in \'smtp.auth\' configuration.');
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ export function loadConfig(path: string): AppConfig {
148
+ try {
149
+ const fileContents = fs.readFileSync(path, 'utf8');
150
+ const config = yaml.parse(fileContents) as AppConfig;
151
+
152
+ // Validate the parsed configuration
153
+ validateConfig(config);
154
+
155
+ return config;
156
+ }
157
+ catch (error) {
158
+ if (error instanceof SyntaxError) {
159
+ throw new Error(`YAML Syntax Error in config file: ${error.message}`);
160
+ }
161
+ else if (error.code === 'ENOENT') {
162
+ throw new Error(`Configuration file not found at path: ${path}`);
163
+ }
164
+ else {
165
+ throw new Error(`Failed to load configuration: ${error.message}`);
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,35 @@
1
+ import { BackupConfig } from '@/backup/backup.service.js';
2
+ import { MongoDbConfig } from '@/database/mongo.service.js';
3
+ import { PostgresConfig } from '@/database/postgres.service.js';
4
+ import { EmailConfig } from '@/mailer/mailer.service.js';
5
+ import { OCIStorageProviderConfiguration } from '@tmlmobilidade/interfaces';
6
+
7
+ export interface StorageConfig {
8
+ oci_config?: OCIStorageProviderConfiguration
9
+ type: 'oci'
10
+ }
11
+
12
+ export interface MongoDBOptions {
13
+ connectTimeoutMS: number
14
+ directConnection: boolean
15
+ dump_options?: {
16
+ exclude_collections?: string[]
17
+ }
18
+ maxPoolSize: number
19
+ minPoolSize: number
20
+ readPreference: string
21
+ serverSelectionTimeoutMS: number
22
+ }
23
+
24
+ export interface DatabaseConfig {
25
+ mongodb_config?: MongoDbConfig
26
+ postgres_config?: PostgresConfig
27
+ type: 'mongodb' | 'postgres'
28
+ }
29
+
30
+ export interface AppConfig {
31
+ backup: BackupConfig
32
+ database: DatabaseConfig
33
+ email?: EmailConfig
34
+ storage: StorageConfig
35
+ }
@@ -0,0 +1,35 @@
1
+ /* eslint-disable @typescript-eslint/no-extraneous-class */
2
+ import { IDatabaseService } from './database.interface.js';
3
+ import { MongoDbConfig, MongoDbService } from './mongo.service.js';
4
+ import { PostgresConfig, PostgresService } from './postgres.service.js';
5
+
6
+ export interface DatabaseConfiguration {
7
+ mongodb_config?: MongoDbConfig
8
+ postgres_config?: PostgresConfig
9
+ type: 'mongodb' | 'postgres'
10
+ }
11
+
12
+ export class DatabaseFactory {
13
+ /**
14
+ * Creates and returns an instance of a database service based on the provided configuration.
15
+ *
16
+ * @param config - The database configuration object.
17
+ * @returns An instance of a class that implements IDatabaseService.
18
+ */
19
+ public static create(config: DatabaseConfiguration): IDatabaseService {
20
+ switch (config.type) {
21
+ case 'mongodb':
22
+ if (!config.mongodb_config || !config.mongodb_config.uri) {
23
+ throw new Error('MongoDB configuration is missing or incomplete.');
24
+ }
25
+ return MongoDbService.getInstance(config.mongodb_config);
26
+ case 'postgres':
27
+ if (!config.postgres_config || !config.postgres_config.uri) {
28
+ throw new Error('PostgreSQL configuration is missing or incomplete.');
29
+ }
30
+ return new PostgresService(config.postgres_config);
31
+ default:
32
+ throw new Error(`Unsupported database type: ${config.type}`);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,21 @@
1
+ export interface IDatabaseService {
2
+ /**
3
+ * Backs up the database.
4
+ */
5
+ backup(outputPath: string): Promise<void>
6
+
7
+ /**
8
+ * Connects to the database.
9
+ */
10
+ connect(): Promise<unknown>
11
+
12
+ /**
13
+ * Disconnects from the database.
14
+ */
15
+ disconnect(): Promise<unknown>
16
+
17
+ /**
18
+ * Restores the database from the latest backup.
19
+ */
20
+ restore(backupPath: string): Promise<void>
21
+ }
@@ -0,0 +1,172 @@
1
+ import archiver from 'archiver';
2
+ import { exec } from 'child_process';
3
+ import fs from 'fs';
4
+ import { Collection, Db, DbOptions, MongoClient, MongoClientOptions } from 'mongodb';
5
+ import path from 'path';
6
+
7
+ import { IDatabaseService } from './database.interface.js';
8
+
9
+ export interface MongoDbConfig {
10
+ /** Options to control dump behavior */
11
+ dump_options?: {
12
+ database?: string
13
+ exclude_collections?: string[]
14
+ }
15
+ options?: MongoClientOptions
16
+ uri: string
17
+ }
18
+
19
+ export class MongoDbService implements IDatabaseService {
20
+ private static _instance: MongoDbService;
21
+ get client(): MongoClient {
22
+ return this._client;
23
+ }
24
+
25
+ private _client: MongoClient;
26
+
27
+ private _dumpOptions?: MongoDbConfig['dump_options'];
28
+ private _uri: string;
29
+
30
+ constructor(config: MongoDbConfig) {
31
+ this._client = new MongoClient(config.uri, config.options);
32
+ this._uri = config.uri;
33
+ this._dumpOptions = config.dump_options;
34
+
35
+ this._client.on('close', () => {
36
+ console.warn('MongoDB connection closed unexpectedly.');
37
+ });
38
+ this._client.on('reconnect', () => {
39
+ console.log('MongoDB reconnected.');
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Get the singleton instance of MongoDbService.
45
+ */
46
+ public static getInstance(config?: MongoDbConfig): MongoDbService {
47
+ if (!MongoDbService._instance) {
48
+ if (!config?.uri) {
49
+ throw new Error('MongoDB URI is required');
50
+ }
51
+
52
+ MongoDbService._instance = new MongoDbService(config);
53
+ }
54
+
55
+ return MongoDbService._instance;
56
+ }
57
+
58
+ /**
59
+ * Perform a MongoDB dump using mongodump command.
60
+ * @param outputPath - The directory path where the dump will be saved.
61
+ * @returns A promise that resolves on successful dump or rejects on error.
62
+ */
63
+ async backup(outputPath: string): Promise<void> {
64
+ const dumpDir = path.resolve(outputPath);
65
+
66
+ return new Promise((resolve, reject) => {
67
+ const excludeFlags = this._dumpOptions?.exclude_collections?.map(c => `--excludeCollection="${c}"`).join(' ') || '';
68
+ const databaseFlag = this._dumpOptions?.database ? `--db="${this._dumpOptions.database}"` : '';
69
+
70
+ const command = `mongodump --uri="${this._uri}" --out="${dumpDir}" ${excludeFlags} ${databaseFlag}`.trim();
71
+
72
+ exec(command, (error, stdout, stderr) => {
73
+ if (error) {
74
+ console.error(`⤷ Error running mongodump: ${stderr}`);
75
+ reject(new Error(`Mongodump failed: ${stderr}`));
76
+ }
77
+ else {
78
+ console.log(`⤷ Mongodump completed successfully`);
79
+
80
+ // Create a file to stream archive data to.
81
+ const output = fs.createWriteStream(`${dumpDir}.zip`);
82
+ const archive = archiver('zip', {
83
+ zlib: { level: 9 }, // Sets the compression level.
84
+ });
85
+
86
+ archive.on('error', (err) => {
87
+ console.error(`⤷ Error creating zip: ${err.message}`);
88
+ reject(err);
89
+ });
90
+
91
+ // Pipe archive data to the file.
92
+ archive.pipe(output);
93
+
94
+ // Append files from the dump directory.
95
+ archive.directory(dumpDir, false);
96
+
97
+ // Finalize the archive (i.e., finish the compression).
98
+ archive.finalize();
99
+
100
+ // Remove the dump directory after zipping.
101
+ output.on('close', () => {
102
+ fs.rmSync(dumpDir, { recursive: true });
103
+ console.log(`⤷ Backup has been zipped successfully. Total bytes: ${archive.pointer()}`);
104
+ resolve();
105
+ });
106
+ }
107
+ });
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Connect to MongoDB and return the database instance.
113
+ */
114
+ async connect(): Promise<MongoClient> {
115
+ if (!this._client) {
116
+ try {
117
+ await this._client.connect();
118
+ console.log('⤷ Connected to MongoDB.');
119
+ }
120
+ catch (error) {
121
+ throw new Error('Error connecting to MongoDB', { cause: error });
122
+ }
123
+ }
124
+ return this._client;
125
+ }
126
+
127
+ /**
128
+ * Create a new Db instance sharing the current socket connections.
129
+ *
130
+ * @param dbName - The name of the database we want to use. If not provided, use database name from connection string.
131
+ * @param options - Optional settings for Db construction
132
+ */
133
+ db(dbName?: string, options?: DbOptions): Db {
134
+ return this._client.db(dbName, options);
135
+ }
136
+
137
+ /**
138
+ * Close the MongoDB connection.
139
+ */
140
+ async disconnect(): Promise<void> {
141
+ if (this._client) {
142
+ await this._client.close();
143
+ console.log('⤷ Disconnected from MongoDB.');
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get a specific collection by name.
149
+ * @param db - The database instance.
150
+ * @param collectionName - The name of the collection to retrieve.
151
+ * @returns The collection instance.
152
+ */
153
+ async getCollection<T>(db: Db, collectionName: string): Promise<Collection<T>> {
154
+ return db.collection<T>(collectionName);
155
+ }
156
+
157
+ /**
158
+ * Restores the database from the provided backup file.
159
+ */
160
+ async restore(backupPath: string): Promise<void> {
161
+ const command = `mongorestore --uri="${this._uri}" --drop "${backupPath}"`;
162
+
163
+ exec(command, (error, stdout, stderr) => {
164
+ if (error) {
165
+ console.error(`⤷ Error running mongorestore: ${stderr}`);
166
+ }
167
+ else {
168
+ console.log(`⤷ Mongorestore completed successfully:\n${stdout}`);
169
+ }
170
+ });
171
+ }
172
+ }
@@ -0,0 +1,95 @@
1
+ /* * */
2
+
3
+ import { IDatabaseService } from '@/database/database.interface.js';
4
+ import { exec, spawn } from 'child_process';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { Client, type ClientConfig } from 'pg';
8
+
9
+ /* * */
10
+
11
+ export interface PostgresConfig {
12
+ options?: ClientConfig
13
+ uri: string
14
+ }
15
+
16
+ export class PostgresService implements IDatabaseService {
17
+ private client: InstanceType<typeof Client>;
18
+
19
+ constructor(config: PostgresConfig) {
20
+ this.client = new Client({ connectionString: config.uri, ...config.options });
21
+ }
22
+
23
+ /**
24
+ * Backs up the database to the specified output path.
25
+ */
26
+ async backup(outputPath: string): Promise<void> {
27
+ return new Promise((resolve, reject) => {
28
+ // Ensure the output directory exists
29
+ const outputDir = path.dirname(outputPath);
30
+ if (!fs.existsSync(outputDir)) {
31
+ fs.mkdirSync(outputDir, { recursive: true });
32
+ }
33
+
34
+ // Prepare the pg_dump command arguments
35
+ const args = [
36
+ '--dbname', this.client.database,
37
+ '--format', 'custom',
38
+ '--file', outputPath,
39
+ ];
40
+
41
+ // Spawn the pg_dump process
42
+ const dump = spawn('pg_dump', args);
43
+
44
+ dump.stdout.on('data', (data) => {
45
+ console.log(`pg_dump stdout: ${data}`);
46
+ });
47
+
48
+ dump.stderr.on('data', (data) => {
49
+ console.error(`pg_dump stderr: ${data}`);
50
+ });
51
+
52
+ dump.on('close', (code) => {
53
+ if (code === 0) {
54
+ console.log(`Backup completed successfully. File saved to ${outputPath}`);
55
+ resolve();
56
+ }
57
+ else {
58
+ reject(new Error(`pg_dump exited with code ${code}`));
59
+ }
60
+ });
61
+
62
+ dump.on('error', (err) => {
63
+ reject(err);
64
+ });
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Connects to the PostgreSQL database.
70
+ */
71
+ async connect(): Promise<void> {
72
+ await this.client.connect();
73
+ console.log('Connected to PostgreSQL.');
74
+ }
75
+
76
+ /**
77
+ * Disconnects from the PostgreSQL database.
78
+ */
79
+ async disconnect(): Promise<void> {
80
+ await this.client.end();
81
+ console.log('Disconnected from PostgreSQL.');
82
+ }
83
+
84
+ /**
85
+ * Restores the database from the provided backup file.
86
+ */
87
+ async restore(backupPath: string): Promise<void> {
88
+ const command = `pg_restore --dbname="${this.client.database}" --file="${backupPath}"`;
89
+ exec(command, (error, stdout, stderr) => {
90
+ if (error) {
91
+ console.error(`⤷ Error running pg_restore: ${stderr}`);
92
+ }
93
+ });
94
+ }
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { StorageConfiguration, StorageFactory } from '@tmlmobilidade/interfaces';
2
+
3
+ import { BackupService } from './backup/backup.service.js';
4
+ import { loadConfig } from './config/config-loader.js';
5
+ import { DatabaseConfiguration, DatabaseFactory } from './database/database.factory.js';
6
+ import { MailerService } from './mailer/mailer.service.js';
7
+
8
+ async function main() {
9
+ const config = loadConfig(process.env.CONFIG_PATH || './config.yaml');
10
+
11
+ const databaseConfig: DatabaseConfiguration = {
12
+ mongodb_config: config.database.mongodb_config,
13
+ postgres_config: config.database.postgres_config,
14
+ type: config.database.type,
15
+ };
16
+
17
+ const storageConfig: StorageConfiguration = {
18
+ oci_config: config.storage.oci_config,
19
+ type: config.storage.type,
20
+ };
21
+
22
+ // Create database and storage services
23
+ const database = DatabaseFactory.create(databaseConfig);
24
+ const storage = StorageFactory.create(storageConfig);
25
+ const backup = new BackupService(config.backup, database, storage);
26
+ const mailer = new MailerService(config.email);
27
+
28
+ console.log('Running backup...');
29
+
30
+ try {
31
+ // Connect to the database
32
+ await database.connect();
33
+
34
+ // Perform backup
35
+ await backup.backup();
36
+
37
+ if (config.email?.send_success) {
38
+ await mailer.sendSuccessMail();
39
+ }
40
+ }
41
+ catch (error) {
42
+ console.error('Error during backup:', error);
43
+
44
+ if (config.email?.send_failure) {
45
+ await mailer.sendFailureMail(error.message);
46
+ }
47
+ }
48
+ finally {
49
+ // Disconnect from the database
50
+ await database.disconnect();
51
+ }
52
+
53
+ await new Promise(resolve => setTimeout(resolve, config.backup.interval * 1000 * 60));
54
+ main();
55
+ }
56
+
57
+ main().catch(async (err) => {
58
+ console.error(err);
59
+ await new Promise(resolve => setTimeout(resolve, 1000 * 60)); // Wait for 1 minute before running again
60
+ main();
61
+ });
@@ -0,0 +1,71 @@
1
+ import { Dates } from '@tmlmobilidade/dates';
2
+ import { RenderFailedBackupEmail } from '@tmlmobilidade/emails';
3
+ import nodemailer, { Transporter } from 'nodemailer';
4
+
5
+ export interface MailOptions {
6
+ from: string
7
+ html?: string
8
+ subject: string
9
+ text?: string
10
+ to: string | string[]
11
+ }
12
+
13
+ export interface SmtpConfig {
14
+ auth: {
15
+ pass: string
16
+ user: string
17
+ }
18
+ host: string
19
+ port: number
20
+ }
21
+
22
+ export interface EmailConfig {
23
+ mail_options: Omit<MailOptions, 'html' | 'text'>
24
+ send_failure: boolean
25
+ send_success: boolean
26
+ smtp: SmtpConfig
27
+ }
28
+
29
+ export class MailerService {
30
+ private transporter: Transporter;
31
+
32
+ constructor(private config: EmailConfig) {
33
+ this.transporter = nodemailer.createTransport(this.config.smtp);
34
+ }
35
+
36
+ public async sendFailureMail(error: string): Promise<void> {
37
+ const emailHtml = await RenderFailedBackupEmail({
38
+ backup_service: this.config.mail_options.subject,
39
+ error_message: error,
40
+ failure_time: Dates.now('Europe/Lisbon').toLocaleString(Dates.FORMATS.DATETIME_FULL_WITH_SECONDS),
41
+ });
42
+
43
+ const mail_options = {
44
+ ...this.config.mail_options,
45
+ html: emailHtml,
46
+ subject: `${this.config.mail_options.subject}: Falha na execução do backup`,
47
+ };
48
+
49
+ await this.sendMail(mail_options);
50
+ }
51
+
52
+ public async sendSuccessMail(): Promise<void> {
53
+ const mail_options = {
54
+ ...this.config.mail_options,
55
+ subject: `${this.config.mail_options.subject}: Backup successful`,
56
+ text: 'Backup was successful',
57
+ };
58
+
59
+ await this.sendMail(mail_options);
60
+ }
61
+
62
+ private async sendMail(mail_options: MailOptions): Promise<void> {
63
+ try {
64
+ const info = await this.transporter.sendMail(mail_options);
65
+ console.log(`Email sent: ${info.messageId}`);
66
+ }
67
+ catch (error) {
68
+ console.error('Error sending email:', error);
69
+ }
70
+ }
71
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "@tmlmobilidade/tsconfig/nodejs.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "outDir": "dist",
6
+ "paths": {
7
+ "@/*": ["./src/*"]
8
+ }
9
+ },
10
+ "include": ["src"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }