@live-change/backup-service 0.8.3
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/backup.js +98 -0
- package/definition.js +11 -0
- package/download_backup.sh +16 -0
- package/index.js +166 -0
- package/package.json +22 -0
- package/restore.js +12 -0
- package/upload_backup.sh +15 -0
package/backup.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import App from '@live-change/framework'
|
|
2
|
+
const app = App.app()
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import fse from 'fs-extra'
|
|
5
|
+
import util from 'util'
|
|
6
|
+
import { exec as cpExec } from 'child_process'
|
|
7
|
+
const exec = util.promisify(cpExec)
|
|
8
|
+
import dump from '@live-change/db-client/lib/dump.js'
|
|
9
|
+
import { once } from 'events'
|
|
10
|
+
import os from "os"
|
|
11
|
+
import path from 'path'
|
|
12
|
+
|
|
13
|
+
function currentBackupPath(backupsDir = './backups/') {
|
|
14
|
+
const dateString = new Date().toISOString().slice(0,-1).replace(/[T:\\.-]/gi, '_')
|
|
15
|
+
return `${backupsDir}/${dateString.substring(0, dateString.length - 1)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function writeDbBackup(stream) {
|
|
19
|
+
let drainPromise
|
|
20
|
+
async function write(object) {
|
|
21
|
+
const code = JSON.stringify(object)
|
|
22
|
+
if(!stream.write(code + '\n')) {
|
|
23
|
+
if(!drainPromise) drainPromise = once(stream, 'drain')
|
|
24
|
+
await drainPromise
|
|
25
|
+
drainPromise = null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await dump({
|
|
29
|
+
dao: app.dao,
|
|
30
|
+
db: app.databaseName,
|
|
31
|
+
//serverUrl: process.env.DB_URL || 'http://localhost:9417/api/ws',
|
|
32
|
+
//db: process.env.DB_NAME || 'test',
|
|
33
|
+
structure: true,
|
|
34
|
+
verbose: true
|
|
35
|
+
},
|
|
36
|
+
(method, ...args) => write({ type: 'request', method, parameters: args }),
|
|
37
|
+
() => write({ type: 'sync' })
|
|
38
|
+
)
|
|
39
|
+
stream.end()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createBackup(backupPath = currentBackupPath()) {
|
|
43
|
+
await fs.promises.mkdir(backupPath)
|
|
44
|
+
|
|
45
|
+
const dbStream = fs.createWriteStream(path.resolve(backupPath, 'db.json'))
|
|
46
|
+
await writeDbBackup(dbStream)
|
|
47
|
+
|
|
48
|
+
const version = await (
|
|
49
|
+
fs.promises.readFile('./package.json', { encoding: 'utf8' })
|
|
50
|
+
.catch(e => 'unknown')
|
|
51
|
+
.then(data => JSON.parse(data).version)
|
|
52
|
+
)
|
|
53
|
+
const info = {
|
|
54
|
+
version: version,
|
|
55
|
+
hostname: os.hostname(),
|
|
56
|
+
directory: path.resolve('.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await fs.promises.writeFile(path.resolve(backupPath, 'info.json'), JSON.stringify(info, null, ' '))
|
|
60
|
+
|
|
61
|
+
await fse.copy("./storage", path.resolve(backupPath, "storage"))
|
|
62
|
+
|
|
63
|
+
const command = `tar -zcf ../${path.basename(backupPath)}.tar.gz.tmp storage info.json db.json`
|
|
64
|
+
console.log("EXEC TAR COMMAND:", command)
|
|
65
|
+
await exec(command, { cwd: backupPath })
|
|
66
|
+
|
|
67
|
+
await fs.promises.rename(backupPath + '.tar.gz.tmp', backupPath + '.tar.gz')
|
|
68
|
+
|
|
69
|
+
await fse.remove(backupPath)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function removeOldBackups(backupsDir = '../../backups/', maxAge = 10*24*3600*1000, minBackups = 10) {
|
|
73
|
+
const backupFiles = await fs.promises.readdir(backupsDir)
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
// read backup times from filenames
|
|
76
|
+
const backups = backupFiles.map(file => {
|
|
77
|
+
const match = file.match(/(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d+)\.tar\.gz/)
|
|
78
|
+
if(!match) {
|
|
79
|
+
console.error("BACKUP FILE NAME DOES NOT MATCH:", file)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
const [_full, year, month, day, hour, minute, second, millis] = match
|
|
83
|
+
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`)
|
|
84
|
+
return { file, date }
|
|
85
|
+
}).filter(backup => backup !== null)
|
|
86
|
+
// sort by date, from oldest to newest
|
|
87
|
+
backups.sort((a, b) => a.date - b.date)
|
|
88
|
+
const olderBackups = backups
|
|
89
|
+
.slice(0, backups.length - minBackups) // leave at least minBackups
|
|
90
|
+
.filter(backup => now - backup.date > maxAge) // leave only backups older than maxAge
|
|
91
|
+
for(const backup of olderBackups) {
|
|
92
|
+
const backupPath = path.resolve(backupsDir, backup.file)
|
|
93
|
+
console.log("REMOVING OLD BACKUP:", backupPath)
|
|
94
|
+
await fse.remove(backupPath)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { createBackup, currentBackupPath, removeOldBackups }
|
package/definition.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import App from '@live-change/framework'
|
|
2
|
+
const app = App.app()
|
|
3
|
+
|
|
4
|
+
import relationsPlugin from '@live-change/relations-plugin'
|
|
5
|
+
|
|
6
|
+
const definition = app.createServiceDefinition({
|
|
7
|
+
name: "backup",
|
|
8
|
+
use: [ relationsPlugin ]
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export default definition
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
BACKUP_SERVER_URL=$1
|
|
4
|
+
BACKUP_SERVER_USERNAME=$2
|
|
5
|
+
BACKUP_SERVER_PASSWORD=$3
|
|
6
|
+
|
|
7
|
+
mkdir -p backups
|
|
8
|
+
|
|
9
|
+
echo Checking backup on "$BACKUP_SERVER_URL/latest"
|
|
10
|
+
|
|
11
|
+
LATEST_BACKUP_URL=`curl -L -u "$BACKUP_SERVER_USERNAME:$BACKUP_SERVER_PASSWORD" "$BACKUP_SERVER_URL/latest"`
|
|
12
|
+
echo Downloading latest backup from url $LATEST_BACKUP_URL
|
|
13
|
+
pushd backups
|
|
14
|
+
curl -L -u "$BACKUP_SERVER_USERNAME:$BACKUP_SERVER_PASSWORD" $LATEST_BACKUP_URL -O -J
|
|
15
|
+
echo "$(basename -- $LATEST_BACKUP_URL)" > latest.txt
|
|
16
|
+
popd
|
package/index.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import App from '@live-change/framework'
|
|
2
|
+
const app = App.app()
|
|
3
|
+
import definition from './definition.js'
|
|
4
|
+
const config = definition.config
|
|
5
|
+
export default definition
|
|
6
|
+
|
|
7
|
+
import express from 'express'
|
|
8
|
+
import basicAuth from 'express-basic-auth'
|
|
9
|
+
const expressApp = express()
|
|
10
|
+
import fs from 'fs'
|
|
11
|
+
import { createBackup, currentBackupPath, removeOldBackups } from './backup.js'
|
|
12
|
+
import { restoreBackup } from './restore.js'
|
|
13
|
+
|
|
14
|
+
import progress from "progress-stream"
|
|
15
|
+
|
|
16
|
+
const TWENTY_FOUR_HOURS = 24 * 3600 * 1000
|
|
17
|
+
const TEN_MINUTES = 10 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
let currentBackup = null
|
|
20
|
+
|
|
21
|
+
const backupServerPort = config.port || process.env.BACKUP_SERVER_PORT || 8007
|
|
22
|
+
const backupServerUsername = config.username || process.env.BACKUP_SERVER_USERNAME
|
|
23
|
+
const backupServerPassword = config.password || process.env.BACKUP_SERVER_PASSWORD
|
|
24
|
+
|
|
25
|
+
const backupsDir = config.dir || './backups'
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(backupsDir, { recursive: true })
|
|
28
|
+
|
|
29
|
+
let queue
|
|
30
|
+
(async () => {
|
|
31
|
+
const PQueue = (await import('p-queue')).default
|
|
32
|
+
queue = new PQueue({ concurrency: 1 })
|
|
33
|
+
})()
|
|
34
|
+
|
|
35
|
+
let users = config.users || {}
|
|
36
|
+
if(backupServerUsername && backupServerPassword ) {
|
|
37
|
+
users[backupServerUsername] = backupServerPassword
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if(Object.keys(users) > 0) {
|
|
41
|
+
expressApp.use(basicAuth({
|
|
42
|
+
users,
|
|
43
|
+
challenge: true,
|
|
44
|
+
realm: 'backupServer',
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function doBackup() {
|
|
49
|
+
if(currentBackup) return currentBackup
|
|
50
|
+
const path = currentBackupPath(backupsDir)
|
|
51
|
+
const filename = path.slice(path.lastIndexOf('/')+1)
|
|
52
|
+
currentBackup = {
|
|
53
|
+
path, filename,
|
|
54
|
+
promise: queue.add(async () => {
|
|
55
|
+
await removeOldBackups(backupsDir, 10*TWENTY_FOUR_HOURS, 10)
|
|
56
|
+
await createBackup(path)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
currentBackup.promise.then(done => {
|
|
60
|
+
console.log("BACKUP CREATED!")
|
|
61
|
+
fs.promises.writeFile(backupsDir + '/latest.txt', filename+'.tar.gz')
|
|
62
|
+
currentBackup = null
|
|
63
|
+
})
|
|
64
|
+
return currentBackup
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expressApp.get('/backup/doBackup', async (req, res) => {
|
|
68
|
+
if(currentBackup) {
|
|
69
|
+
res.status(200).send('Backup in progress: ' + currentBackup.filename)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
await doBackup()
|
|
73
|
+
res.status(200).send('Creating backup: '+currentBackup.filename)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expressApp.get('/backup/latest', async (req, res) => {
|
|
77
|
+
const latest = await fs.promises.readFile(backupsDir + '/latest.txt', { encoding: 'utf8' })
|
|
78
|
+
res.status(200).send(`https://${req.get('host')}/backup/download/${latest}`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expressApp.get('/backup', async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const files = await fs.promises.readdir(backupsDir)
|
|
84
|
+
let backupFiles = files.filter(file => file.endsWith('tar.gz'))
|
|
85
|
+
.map(fileName => `https://${req.get('host')}/backup/download/${fileName}`)
|
|
86
|
+
.reverse()
|
|
87
|
+
.join('\n')
|
|
88
|
+
|
|
89
|
+
res.status(200).header('Content-Type', 'text/plain').send(backupFiles)
|
|
90
|
+
} catch(err) {
|
|
91
|
+
console.log(err)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expressApp.get('/backup/download/:fileName', (req, res) => {
|
|
96
|
+
const { fileName } = req.params
|
|
97
|
+
const file = `${backupsDir}/${fileName}`
|
|
98
|
+
res.status(200).header('Content-Disposition', `attachment; filename="${fileName}"`).download(file)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expressApp.post('/backup/upload/:fileName', async (req, res) => {
|
|
102
|
+
const { fileName } = req.params
|
|
103
|
+
const file = `${backupsDir}/${fileName}`
|
|
104
|
+
|
|
105
|
+
const size = +req.header('Content-Length')
|
|
106
|
+
console.log("SIZE", size)
|
|
107
|
+
|
|
108
|
+
const uploadProgress = progress({
|
|
109
|
+
length: size,
|
|
110
|
+
time: 600
|
|
111
|
+
})
|
|
112
|
+
req.pipe(uploadProgress)
|
|
113
|
+
const fileStream = fs.createWriteStream(file)
|
|
114
|
+
uploadProgress.pipe(fileStream)
|
|
115
|
+
|
|
116
|
+
fileStream.on('finish', () => {
|
|
117
|
+
res.status(200)
|
|
118
|
+
res.send("" + uploadProgress.progress().transferred)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
fileStream.on('error', error => {
|
|
122
|
+
res.status(503)
|
|
123
|
+
res.send("Error " + error.toString())
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
let restoringBackup = false
|
|
128
|
+
expressApp.post('/restore/:fileName', async (req, res) => {
|
|
129
|
+
const { fileName } = req.params
|
|
130
|
+
throw new Error('not implemented')
|
|
131
|
+
if(restoringBackup) {
|
|
132
|
+
res.status(500)
|
|
133
|
+
res.send(`Restoring backup ${restoringBackup} in progress...`)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
restoringBackup = fileName
|
|
137
|
+
const file = `${backupsDir}/${fileName}`
|
|
138
|
+
await restoreBackup({
|
|
139
|
+
file
|
|
140
|
+
})
|
|
141
|
+
restoringBackup = null
|
|
142
|
+
return 'ok'
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
process.on('unhandledRejection', (reason, p) => {
|
|
146
|
+
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
definition.beforeStart(() => {
|
|
150
|
+
|
|
151
|
+
expressApp.listen(backupServerPort, () => {
|
|
152
|
+
console.log(`backup port listening on ${backupServerPort}`)
|
|
153
|
+
setTimeout(async () => {
|
|
154
|
+
console.log('Service init backup:')
|
|
155
|
+
await queue.add(() => doBackup())
|
|
156
|
+
console.log('Service init backup completed')
|
|
157
|
+
}, TEN_MINUTES)
|
|
158
|
+
|
|
159
|
+
setInterval(async () => {
|
|
160
|
+
console.log('Daily backup:')
|
|
161
|
+
await queue.add(() => doBackup())
|
|
162
|
+
console.log('Daily backup completed')
|
|
163
|
+
}, TWENTY_FOUR_HOURS)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@live-change/backup-service",
|
|
3
|
+
"version": "0.8.3",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "NODE_ENV=test blue-tape tests/*"
|
|
8
|
+
},
|
|
9
|
+
"author": "Michał Łaszczewski <michal@emikse.com>",
|
|
10
|
+
"license": "BSD-3-Clause",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@live-change/db-client": "^0.8.3",
|
|
13
|
+
"@live-change/framework": "^0.8.3",
|
|
14
|
+
"express": "^4.18.2",
|
|
15
|
+
"express-basic-auth": "^1.2.1",
|
|
16
|
+
"fs-extra": "^11.1.1",
|
|
17
|
+
"p-queue": "^7.3.4",
|
|
18
|
+
"progress-stream": "^2.0.0"
|
|
19
|
+
},
|
|
20
|
+
"gitHead": "81b4ac5a36ab1abdf9d521e3c4fbea6d194bb892",
|
|
21
|
+
"type": "module"
|
|
22
|
+
}
|
package/restore.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import util from 'util'
|
|
4
|
+
import { exec } from "child_process"
|
|
5
|
+
const execProcess = util.promisify(exec)
|
|
6
|
+
import { once } from 'events'
|
|
7
|
+
import os from "os"
|
|
8
|
+
import path from 'path'
|
|
9
|
+
|
|
10
|
+
export function restoreBackup({ file }) {
|
|
11
|
+
throw new Error('not_implemented')
|
|
12
|
+
}
|
package/upload_backup.sh
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
BACKUP_SERVER_URL=$1
|
|
4
|
+
BACKUP_SERVER_USERNAME=$2
|
|
5
|
+
BACKUP_SERVER_PASSWORD=$3
|
|
6
|
+
BACKUP_FILE=$4
|
|
7
|
+
BACKUP_NAME=(basename $BACKUP_FILE)
|
|
8
|
+
|
|
9
|
+
mkdir -p backups
|
|
10
|
+
|
|
11
|
+
echo Uploading backup $BACKUP_NAME
|
|
12
|
+
UPLOAD_BACKUP_URL="$BACKUP_SERVER_URL/upload/$BACKUP_NAME"`
|
|
13
|
+
pushd backups
|
|
14
|
+
curl -L -u --data-binary "@$BACKUP_FILE" "$BACKUP_SERVER_USERNAME:$BACKUP_SERVER_PASSWORD" $UPLOAD_BACKUP_URL
|
|
15
|
+
popd
|