mirror of https://github.com/certd/certd
perf: sqlite数据库备份插件
parent
5dde5bd3f7
commit
77f163144f
|
@ -122,6 +122,7 @@ http://your_server_ip:7001
|
||||||
```shell
|
```shell
|
||||||
# 克隆代码
|
# 克隆代码
|
||||||
git clone https://github.com/certd/certd
|
git clone https://github.com/certd/certd
|
||||||
|
git checkout v1.26.7 # 这里换成最新版本号
|
||||||
cd certd
|
cd certd
|
||||||
# 启动服务
|
# 启动服务
|
||||||
./start.sh
|
./start.sh
|
||||||
|
|
|
@ -11,6 +11,10 @@ git clone https://github.com/certd/certd
|
||||||
|
|
||||||
#进入项目目录
|
#进入项目目录
|
||||||
cd certd
|
cd certd
|
||||||
|
|
||||||
|
# 切换到最新版本代码
|
||||||
|
git checkout v1.26.7 # 这里换成最新版本号
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 修改pnpm-workspace.yaml文件
|
### 修改pnpm-workspace.yaml文件
|
||||||
|
|
|
@ -9,9 +9,9 @@ export class EabAccess extends BaseAccess {
|
||||||
@AccessInput({
|
@AccessInput({
|
||||||
title: "KID",
|
title: "KID",
|
||||||
component: {
|
component: {
|
||||||
placeholder: "kid",
|
placeholder: "kid / keyId",
|
||||||
},
|
},
|
||||||
helper: "EAB KID",
|
helper: "EAB KID, google的叫 keyId",
|
||||||
required: true,
|
required: true,
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
})
|
})
|
||||||
|
@ -19,9 +19,9 @@ export class EabAccess extends BaseAccess {
|
||||||
@AccessInput({
|
@AccessInput({
|
||||||
title: "HMACKey",
|
title: "HMACKey",
|
||||||
component: {
|
component: {
|
||||||
placeholder: "HMAC Key",
|
placeholder: "HMAC Key / b64MacKey",
|
||||||
},
|
},
|
||||||
helper: "EAB HMAC Key",
|
helper: "EAB HMAC Key ,google的叫b64MacKey",
|
||||||
required: true,
|
required: true,
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
})
|
})
|
||||||
|
@ -32,7 +32,8 @@ export class EabAccess extends BaseAccess {
|
||||||
component: {
|
component: {
|
||||||
placeholder: "绑定一个邮箱",
|
placeholder: "绑定一个邮箱",
|
||||||
},
|
},
|
||||||
helper: "Google EAB 申请证书绑定邮箱后,不能更换,否则会导致EAB失效",
|
rules: { type: "email", message: "请输入正确的邮箱" },
|
||||||
|
helper: "Google的EAB申请证书,更换邮箱会导致EAB失效,可以在此处绑定一个邮箱避免此问题",
|
||||||
required: false,
|
required: false,
|
||||||
})
|
})
|
||||||
email = "";
|
email = "";
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<a-modal v-model:open="taskModal.open" class="pi-task-view" title="任务日志" style="width: 80%" v-bind="taskModal">
|
<a-modal v-model:open="taskModal.open" class="pi-task-view" title="任务日志" style="width: 80%" v-bind="taskModal">
|
||||||
<a-tabs v-model:activeKey="activeKey" tab-position="left" animated>
|
<a-tabs v-model:active-key="activeKey" tab-position="left" animated>
|
||||||
<a-tab-pane v-for="item of detail.nodes" :key="item.node.id">
|
<a-tab-pane v-for="item of detail.nodes" :key="item.node.id">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<div class="tab-title" :title="item.node.title">
|
<div class="tab-title" :title="item.node.title">
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
<pi-status-show :status="item.node.status?.result" type="icon"></pi-status-show>
|
<pi-status-show :status="item.node.status?.result" type="icon"></pi-status-show>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<pre class="pi-task-view-logs" style="overflow: auto;"><template v-for="(text, index) of item.logs" :key="index">{{ text }}</template></pre>
|
<pre class="pi-task-view-logs" style="overflow: auto"><template v-for="(text, index) of item.logs" :key="index">{{ text }}</template></pre>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
@ -56,8 +56,8 @@ export default {
|
||||||
for (let node of nodes) {
|
for (let node of nodes) {
|
||||||
if (currentHistory?.value?.logs != null) {
|
if (currentHistory?.value?.logs != null) {
|
||||||
node.logs = computed(() => {
|
node.logs = computed(() => {
|
||||||
if(currentHistory?.value?.logs && currentHistory.value?.logs[node.node.id]!= null){
|
if (currentHistory?.value?.logs && currentHistory.value?.logs[node.node.id] != null) {
|
||||||
return currentHistory.value?.logs[node.node.id];
|
return currentHistory.value?.logs[node.node.id];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
@ -94,10 +94,11 @@ export default {
|
||||||
display: flex;
|
display: flex;
|
||||||
.tab-title-text {
|
.tab-title-text {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 150px;
|
width: 180px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -178,6 +178,8 @@ export class AsyncSsh2Client {
|
||||||
end() {
|
end() {
|
||||||
if (this.conn) {
|
if (this.conn) {
|
||||||
this.conn.end();
|
this.conn.end();
|
||||||
|
this.conn.destroy();
|
||||||
|
this.conn = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,9 +244,8 @@ export class SshClient {
|
||||||
mkdirCmd = `if not exist "${filePath}" mkdir "${filePath}"`;
|
mkdirCmd = `if not exist "${filePath}" mkdir "${filePath}"`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await conn.shell(mkdirCmd);
|
await conn.exec(mkdirCmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.fastPut({ sftp, ...transport });
|
await conn.fastPut({ sftp, ...transport });
|
||||||
}
|
}
|
||||||
this.logger.info('文件全部上传成功');
|
this.logger.info('文件全部上传成功');
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './plugin-restart.js';
|
export * from './plugin-restart.js';
|
||||||
export * from './plugin-script.js';
|
export * from './plugin-script.js';
|
||||||
export * from './plugin-wait.js';
|
export * from './plugin-wait.js';
|
||||||
|
export * from './plugin-db-backup.js';
|
||||||
|
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SshAccess, SshClient } from '../../plugin-host/index.js';
|
||||||
|
import { AbstractPlusTaskPlugin } from '@certd/plugin-plus';
|
||||||
|
|
||||||
|
const defaultBackupDir = 'certd_backup';
|
||||||
|
const defaultFilePrefix = 'db-backup';
|
||||||
|
@IsTaskPlugin({
|
||||||
|
name: 'DBBackupPlugin',
|
||||||
|
title: '数据库备份',
|
||||||
|
icon: 'ri:rest-time-line',
|
||||||
|
desc: '仅支持备份SQLite数据库',
|
||||||
|
group: pluginGroups.other.key,
|
||||||
|
default: {
|
||||||
|
strategy: {
|
||||||
|
runStrategy: RunStrategy.AlwaysRun,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
needPlus: true,
|
||||||
|
})
|
||||||
|
export class DBBackupPlugin extends AbstractPlusTaskPlugin {
|
||||||
|
@TaskInput({
|
||||||
|
title: '备份方式',
|
||||||
|
value: 'local',
|
||||||
|
component: {
|
||||||
|
name: 'a-select',
|
||||||
|
options: [
|
||||||
|
{ label: '本地复制', value: 'local' },
|
||||||
|
{ label: 'ssh上传', value: 'ssh' },
|
||||||
|
],
|
||||||
|
placeholder: '',
|
||||||
|
},
|
||||||
|
helper: '支持本地复制、ssh上传',
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
backupMode = 'local';
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: '主机登录授权',
|
||||||
|
component: {
|
||||||
|
name: 'access-selector',
|
||||||
|
type: 'ssh',
|
||||||
|
},
|
||||||
|
mergeScript: `
|
||||||
|
return {
|
||||||
|
show:ctx.compute(({form})=>{
|
||||||
|
return form.backupMode === 'ssh';
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
sshAccessId!: number;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: '备份保存目录',
|
||||||
|
component: {
|
||||||
|
name: 'a-input',
|
||||||
|
type: 'value',
|
||||||
|
placeholder: `默认${defaultBackupDir}`,
|
||||||
|
},
|
||||||
|
helper: `ssh方式默认保存在当前用户的${defaultBackupDir}目录下,本地方式默认保存在data/${defaultBackupDir}目录下,也可以填写绝对路径`,
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
backupDir: string = defaultBackupDir;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: '备份文件前缀',
|
||||||
|
component: {
|
||||||
|
name: 'a-input',
|
||||||
|
vModel: 'value',
|
||||||
|
placeholder: `默认${defaultFilePrefix}`,
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
filePrefix: string = defaultFilePrefix;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: '删除过期备份',
|
||||||
|
component: {
|
||||||
|
name: 'a-input-number',
|
||||||
|
vModel: 'value',
|
||||||
|
placeholder: '20',
|
||||||
|
},
|
||||||
|
helper: '删除多少天前的备份,不填则不删除',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
retainDays!: number;
|
||||||
|
|
||||||
|
async onInstance() {}
|
||||||
|
async execute(): Promise<void> {
|
||||||
|
this.logger.info('开始备份数据库');
|
||||||
|
|
||||||
|
let dbPath = process.env.certd_typeorm_dataSource_default_database;
|
||||||
|
dbPath = dbPath || './data/db.sqlite';
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
this.logger.error('数据库文件不存在:', dbPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info('当前备份方式:', this.backupMode);
|
||||||
|
const backupDir = this.backupDir || defaultBackupDir;
|
||||||
|
const backupPath = `${backupDir}/${this.filePrefix}.${dayjs().format('YYYYMMDD.HHmmss')}.sqlite`;
|
||||||
|
|
||||||
|
if (this.backupMode === 'local') {
|
||||||
|
await this.localBackup(dbPath, backupDir, backupPath);
|
||||||
|
} else if (this.backupMode === 'ssh') {
|
||||||
|
await this.sshBackup(dbPath, backupDir, backupPath);
|
||||||
|
} else if (this.backupMode === 'oss') {
|
||||||
|
await this.ossBackup(dbPath, backupDir, backupPath);
|
||||||
|
} else {
|
||||||
|
throw new Error(`不支持的备份方式:${this.backupMode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info('数据库备份完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async localBackup(dbPath: string, backupDir: string, backupPath: string) {
|
||||||
|
if (!backupPath.startsWith('/')) {
|
||||||
|
backupPath = path.resolve('./data/', backupPath);
|
||||||
|
}
|
||||||
|
const dir = backupDir;
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
await fs.promises.copyFile(dbPath, backupPath);
|
||||||
|
this.logger.info('备份文件路径:', backupPath);
|
||||||
|
|
||||||
|
if (this.retainDays > 0) {
|
||||||
|
// 删除过期备份
|
||||||
|
this.logger.info('开始删除过期备份文件');
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
const now = Date.now();
|
||||||
|
let count = 0;
|
||||||
|
files.forEach(file => {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (now - stat.mtimeMs > this.retainDays * 24 * 60 * 60 * 1000) {
|
||||||
|
fs.unlinkSync(filePath as fs.PathLike);
|
||||||
|
count++;
|
||||||
|
this.logger.info('删除过期备份文件:', filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.logger.info('删除过期备份文件数:', count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sshBackup(dbPath: string, backupDir: string, backupPath: string) {
|
||||||
|
const access: SshAccess = await this.ctx.accessService.getById(this.sshAccessId);
|
||||||
|
const sshClient = new SshClient(this.logger);
|
||||||
|
this.logger.info('备份目录:', backupPath);
|
||||||
|
await sshClient.uploadFiles({
|
||||||
|
connectConf: access,
|
||||||
|
transports: [{ localPath: dbPath, remotePath: backupPath }],
|
||||||
|
mkdirs: true,
|
||||||
|
});
|
||||||
|
this.logger.info('备份文件上传完成');
|
||||||
|
|
||||||
|
if (this.retainDays > 0) {
|
||||||
|
// 删除过期备份
|
||||||
|
this.logger.info('开始删除过期备份文件');
|
||||||
|
const isWin = access.windows;
|
||||||
|
let script = '';
|
||||||
|
if (isWin) {
|
||||||
|
throw new Error('删除过期文件暂不支持windows系统');
|
||||||
|
// script = `forfiles /p ${backupDir} /s /d -${this.retainDays} /c "cmd /c del @path"`;
|
||||||
|
} else {
|
||||||
|
script = `find ${backupDir} -type f -mtime +${this.retainDays} -name '${this.filePrefix}*' -exec rm -f {} \\;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sshClient.exec({
|
||||||
|
connectConf: access,
|
||||||
|
script,
|
||||||
|
});
|
||||||
|
this.logger.info('删除过期备份文件完成');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ossBackup(dbPath: string, backupDir: string, backupPath: string) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new DBBackupPlugin();
|
Loading…
Reference in New Issue