/
migration.js
235 lines (198 loc) · 9.32 KB
/
migration.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import path from 'node:path'
import checkDiskSpace from 'check-disk-space'
import drivelist from 'drivelist'
import fse from 'fs-extra'
import { execa } from 'execa'
import pRetry from 'p-retry';
import isUmbrelHome from './is-umbrel-home.js'
let migrationStatus = { running: false, progress: 0, description: '', error: false }
// Update the migrationStatus global
function updateMigrationStatus(properties) {
migrationStatus = { ...migrationStatus, ...properties }
console.log(migrationStatus)
}
// Get the migrationStatus global
export function getMigrationStatus() {
return migrationStatus
}
// Convert bytes integer to GB float
function bytesToGB(bytes) {
return (bytes / 1024 / 1024 / 1024).toFixed(1)
}
// Get a directory size in bytes
async function getDirectorySize(directoryPath) {
let totalSize = 0;
const files = await fse.readdir(directoryPath, { withFileTypes: true });
// Traverse entire directory structure and tally up the size of all files
for (const file of files) {
if (file.isSymbolicLink()) {
const lstats = await fse.lstat(path.join(directoryPath, file.name));
totalSize += lstats.size;
} else if (file.isFile()) {
const stats = await fse.stat(path.join(directoryPath, file.name));
totalSize += stats.size;
} else if (file.isDirectory()) {
totalSize += await getDirectorySize(path.join(directoryPath, file.name));
}
}
return totalSize;
}
// Enumerate attached USB devices and return a path to the first one that is an Umbrel install
// Returns false if no Umbrel install is found
export async function findExternalUmbrelInstall() {
try {
// Get all external drives
const drives = await drivelist.list()
const externalDrives = drives.filter(drive => drive.isUSB && !drive.isSystem)
for (const drive of externalDrives) {
// If the drive is not mounted, mount it
if (drive.mountpoints.length === 0) {
const device = `${drive.device}1` // Mount the first partition
const mountPoint = path.join('/mnt', path.basename(device))
try {
await fse.ensureDir(mountPoint)
await execa('mount', ['--read-only', device, mountPoint])
drive.mountpoints.push({ path: mountPoint })
} catch (error) {
// If there's an error don't bail, keep trying the rest of the drives
console.error(`Error mounting drive: ${error}`)
continue
}
}
// Check if the drive is an Umbrel install
for (const mountpoint of drive.mountpoints) {
const umbrelDotFile = path.join(mountpoint.path, 'umbrel/.umbrel')
// This is an Umbrel install
if (await fse.pathExists(umbrelDotFile)) {
return path.dirname(umbrelDotFile)
}
}
}
// Swallow any errors and just return false
} catch (error) {
console.error(`Error finding external Umbrel install: ${error}`)
}
return false;
}
// Best effort cleanup operation to unmount all external USB devices
export async function unmountExternalDrives() {
try {
// Get all external drives
const drives = await drivelist.list()
const externalDrives = drives.filter(drive => drive.isUSB && !drive.isSystem)
for (const drive of externalDrives) {
for (const mountpoint of drive.mountpoints) {
try {
await execa('umount', [mountpoint.path])
} catch (error) {
// If there's an error don't bail, keep unmounting the rest of the drives
console.error(`Error unmounting drive: ${error}`)
continue
}
}
}
} catch (error) {
// Silently fail, this is just a best effort cleanup operation, we never want
// it to kill the migration process.
}
}
// Run a series of checks and throw a descriptive error if any of them fail
export async function runPreMigrationChecks(currentInstall, externalUmbrelInstall) {
// Check we're running on Umbrel Home hardware
if (!await isUmbrelHome()) {
throw new Error('This feature is only supported on Umbrel Home hardware')
}
// Check migration isn't already running
if (migrationStatus.running) {
throw new Error('Migration is already running')
}
// Check we have an Umbrel install on an external SSD
if (!externalUmbrelInstall) {
throw new Error('No external Umbrel install found')
}
// Check versions match
const { version: previousVersion } = await fse.readJson(`${externalUmbrelInstall}/info.json`)
const { version: currentVersion } = await fse.readJson(`${currentInstall}/info.json`)
// TODO: We might want to loosen this check to a wider range in future updates.
if (previousVersion !== currentVersion) {
throw new Error(`Umbrel versions do not match. Cannot migrate Umbrel ${previousVersion} data in to an Umbrel ${currentVersion} install`)
}
// Check enough storage is available
const temporaryData = `${currentInstall}/.temporary-migration`
await fse.remove(temporaryData)
const { free } = await checkDiskSpace(currentInstall)
const buffer = 1024 * 1024 * 1024 // 1GB
const required = (await getDirectorySize(externalUmbrelInstall)) + buffer
if (free < required) {
throw new Error(`Not enough storage available. ${bytesToGB(free)} GB free, ${bytesToGB(required)} GB required.`)
}
return externalUmbrelInstall
}
// Safely migrate data from an external Umbrel install to the current one
export async function migrateData(currentInstall, externalUmbrelInstall) {
updateMigrationStatus({ running: false, progress: 0, description: '', error: false})
const temporaryData = `${currentInstall}/.temporary-migration`
const statePaths = ['.env', 'db', 'tor', 'repos', 'app-data', 'data']
// Start migration
updateMigrationStatus({ running: true, description: 'Copying data' })
try {
// Copy over state from previous install to temp dir while preserving permissions
const includes = [
...statePaths.map(path => `--include=${path}`),
...statePaths.map(path => `--include=${path}/***`),
]
await fse.remove(temporaryData)
const rsync = execa('rsync', ['--info=progress2', '--archive', '--delete', ...includes, `--exclude=*`, `${externalUmbrelInstall}/`, temporaryData])
// Update migration status with rsync progress
rsync.stdout.on('data', chunk => {
const progressUpdate = chunk.toString().match(/.* ([0-9]*)% .*/)
if (progressUpdate) {
const percent = parseInt(progressUpdate[1], 10)
// Show file copy percentage as 75% of total migration progress
const progress = parseInt(0.75 * percent, 10)
if (progress > migrationStatus.progress) updateMigrationStatus({ progress })
}
})
// Wait for rsync to finish
await rsync
// Preserve new installation password
const temporaryInfoJson = await fse.readJson(`${temporaryData}/db/user.json`)
const { password: currentInstallPasword } = await fse.readJson(`${currentInstall}/db/user.json`)
await fse.writeJson(`${temporaryData}/db/user.json`, {
...temporaryInfoJson,
password: currentInstallPasword
}, { spaces: 2 })
// Stop apps / umbrel
updateMigrationStatus({ progress: 80, description: 'Stopping Umbrel' })
await execa('./scripts/stop', ['--no-stop-server'], { cwd: currentInstall })
// Move data from temp dir to current install
// This is the only dangerous action in the migration process, before this action the Umbrel state is still intact
// After this action the Umbrel state should be fully migrated. We previously copied all the data to the same filesystem
// as the Umbrel install, so we can do this risky step with a quick rename operation (fse.move) which just updates a
// pointer and doesn't actually move any data. This means this operation is very fast, reducing the chance of leaving the install
// in a broken state.
updateMigrationStatus({ progress: 85, description: 'Linking new data' })
for (const path of statePaths) {
const temporaryPath = `${temporaryData}/${path}`
if (await fse.pathExists(temporaryPath)) {
await fse.move(temporaryPath, `${currentInstall}/${path}`, { overwrite: true })
}
}
} catch (error) {
console.error(error)
updateMigrationStatus({error: 'Failed to migrate data'})
}
// Clean up temp dir
try {
updateMigrationStatus({ progress: 90, description: 'Cleaning up' })
await fse.remove(temporaryData)
} catch (error) {}
// Start apps / umbrel
updateMigrationStatus({ progress: 95, description: 'Starting Umbrel' })
await pRetry(() => execa('./scripts/start', ['--no-start-server'], { cwd: currentInstall }), {
retries: 5,
})
updateMigrationStatus({ running: false, progress: 100, description: '' })
// Cleanup mounted drives
await unmountExternalDrives()
}