diff --git a/cspell.words.txt b/cspell.words.txt index 2866c81..3bcb224 100644 --- a/cspell.words.txt +++ b/cspell.words.txt @@ -3,4 +3,5 @@ datasource datasources Booter Mustermann -Musterfrau \ No newline at end of file +Musterfrau +mysqldump \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index daea25c..9215827 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lbx-change-sets", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lbx-change-sets", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "tslib": "^2.6.1" diff --git a/package.json b/package.json index 189a12d..057b39e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lbx-change-sets", - "version": "1.1.1", + "version": "1.1.2", "description": "lbx-change-sets", "keywords": [ "loopback-extension", @@ -61,4 +61,4 @@ "source-map-support": "^0.5.21", "typescript": "~5.1.6" } -} \ No newline at end of file +} diff --git a/showcase/src/controllers/test.controller.ts b/showcase/src/controllers/test.controller.ts index b49dd81..5ce3cb4 100644 --- a/showcase/src/controllers/test.controller.ts +++ b/showcase/src/controllers/test.controller.ts @@ -1,7 +1,9 @@ +import { service } from '@loopback/core'; import { Model, model, property, repository } from '@loopback/repository'; import { del, get, getModelSchemaRef, param, patch, post, put, requestBody } from '@loopback/rest'; import { TestChangeSetEntity } from '../models'; import { TestChangeSetEntityRepository } from '../repositories'; +import { BackupService } from '../services'; @model() class RollbackModel extends Model { @@ -19,9 +21,24 @@ class RollbackModel extends Model { export class TestController { constructor( @repository(TestChangeSetEntityRepository) - public testRepository: TestChangeSetEntityRepository + public testRepository: TestChangeSetEntityRepository, + @service(BackupService) + private readonly backupService: BackupService ) {} + @post('/create-backup') + async createBackup(): Promise { + await this.backupService.createBackup(); + } + + @post('/restore-backup') + async restoreBackup( + @requestBody() + date: Date + ): Promise { + await this.backupService.restoreBackupFromDate(date); + } + @post('/test') async create( @requestBody({ diff --git a/showcase/src/services/backup.service.ts b/showcase/src/services/backup.service.ts new file mode 100644 index 0000000..32228c9 --- /dev/null +++ b/showcase/src/services/backup.service.ts @@ -0,0 +1,31 @@ +import { BindingScope, bind } from '@loopback/core'; +import { HttpErrors } from '@loopback/rest'; +import { MySqlBackupService } from 'lbx-change-sets'; + +@bind({ scope: BindingScope.TRANSIENT }) +export class BackupService extends MySqlBackupService { + protected override readonly rootPw: string = 'C2bcAJ2woT8UMewungW7qeFpjxwG3qTfB2GudQmv'; + + protected override async saveBackup(): Promise { + await this.execAsync(`cp ${this.backupTempName} backup-${this.formatDate(new Date())}.sql`); + } + + async restoreBackupFromDate(date: Date): Promise { + this.loadBackupForDate(date) + await this.restoreMySqlData(); + await this.removeTempRestoreBackup(); + } + + protected async loadBackupForDate(date: Date): Promise { + try { + await this.execAsync(`cp backup-${this.formatDate(date)}.sql ${this.restoreBackupTempName}`); + } + catch (error) { + throw new HttpErrors.BadRequest(`Could not find a backup for the date ${this.formatDate(date)}`) + } + } + + private formatDate(date: Date): string { + return new Date(date).toLocaleDateString('de', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } +} \ No newline at end of file diff --git a/showcase/src/services/index.ts b/showcase/src/services/index.ts new file mode 100644 index 0000000..986379a --- /dev/null +++ b/showcase/src/services/index.ts @@ -0,0 +1 @@ +export * from './backup.service' \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c7bb666..f4a91e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './component'; export * from './keys'; -export * from './types'; export * from './models'; -export * from './repositories'; \ No newline at end of file +export * from './repositories'; +export * from './types'; +export * from './services'; \ No newline at end of file diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..5fe4bd6 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1 @@ +export * from './mysql-backup.service'; \ No newline at end of file diff --git a/src/services/mysql-backup.service.ts b/src/services/mysql-backup.service.ts new file mode 100644 index 0000000..a057546 --- /dev/null +++ b/src/services/mysql-backup.service.ts @@ -0,0 +1,125 @@ +import { inject } from '@loopback/core'; +import { IsolationLevel, juggler } from '@loopback/repository'; +import { File } from 'buffer'; +import { exec } from 'child_process'; +import { rm, writeFile } from 'fs/promises'; +import { LbxChangeSetsBindings } from '../keys'; + +/** + * A backup service that uses the mysqldump utility to create and restore backups. + */ +export abstract class MySqlBackupService { + + /** + * The root database password. Is needed to backup everything, including system and user tables. + */ + protected abstract readonly rootPw: string; + + /** + * The host of the database. + * @default '127.0.0.1' + */ + protected readonly host: string = '127.0.0.1'; + + /** + * The name of the temporary backup file. Is saved in the root directory of the project. + * @default 'backup.temp.sql' + */ + protected readonly backupTempName: string = 'backup.temp.sql'; + + /** + * The name of the temporary restore backup file. Is saved in the root directory of the project. + * @default 'restore-backup.temp.sql' + */ + protected readonly restoreBackupTempName: string = 'restore-backup.temp.sql'; + + constructor( + @inject(LbxChangeSetsBindings.DATASOURCE_KEY) + private readonly dataSource: juggler.DataSource + ) {} + + /** + * Creates a backup. + */ + async createBackup(): Promise { + const transaction: juggler.Transaction = await this.dataSource.beginTransaction(IsolationLevel.READ_COMMITTED); + try { + await this.createDump(); + await this.saveBackup(); + await this.removeTempBackup(); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } + } + + /** + * Restores the backup from the given date. + * @param dump - THe mysql dump file to restore from. + */ + async restoreBackup(dump: File): Promise { + await this.loadBackup(dump); + await this.restoreMySqlData(); + await this.removeTempRestoreBackup(); + } + + /** + * Creates a mysql dump and saves it under this.backupTempName. + */ + protected async createDump(): Promise { + await this.execAsync(`mysqldump --all-databases -h ${this.dataSource.settings['host']} -u root -p${this.rootPw} > ${this.backupTempName}`, 'Could not create a mysql dump'); + } + + /** + * Method that handles saving the temporary dump file under this.backupTempName before it gets deleted. + */ + protected abstract saveBackup(): Promise; + + /** + * Deletes the temporary backup file. + */ + protected async removeTempBackup(): Promise { + await rm(this.backupTempName); + } + + /** + * Deletes the temporary restore backup file. + */ + protected async removeTempRestoreBackup(): Promise { + await rm(this.restoreBackupTempName); + } + + /** + * Restores the mysql data from the file under this.restoreBackupTempName. + */ + async restoreMySqlData(): Promise { + await this.execAsync(`mysql -h ${this.dataSource.settings['host']} -u root -p${this.rootPw} < ${this.restoreBackupTempName}`, 'Could not restore the sql dump'); + } + + /** + * Loads the backup file for the given date into this.restoreBackupTempName. + * @param dump - The mysql dump file. + */ + protected async loadBackup(dump: File): Promise { + await writeFile(this.restoreBackupTempName, dump.stream()); + } + + /** + * Runs the exec method from the child_process package as async. + * @param command - The command to execute. + * @param errorMessage - The error message to display if the command fails. + */ + protected async execAsync(command: string, errorMessage: string = 'Error executing the command'): Promise { + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(errorMessage); + return; + } + resolve(); + }); + }); + } +} \ No newline at end of file