@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 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
+ }
@@ -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