feat(sdk): Move CLI writer logic into SDK writer

This moves the preparation logic from the CLI into the SDK
in preparation for further SDK rearchitecturing, and to allow
standalone usage of SDK.

Change-Type: minor
Changelog-Entry: Move CLI write preparation logic into SDK
This commit is contained in:
Jonas Hermsmeier 2018-02-23 16:06:07 +01:00
parent ee93013220
commit d9ccc43d15
No known key found for this signature in database
GPG Key ID: 1B870F801A0CEE9F
2 changed files with 287 additions and 118 deletions

View File

@ -16,24 +16,8 @@
'use strict'
const _ = require('lodash')
const Bluebird = require('bluebird')
const fs = Bluebird.promisifyAll(require('fs'))
const mountutils = Bluebird.promisifyAll(require('mountutils'))
const drivelist = Bluebird.promisifyAll(require('drivelist'))
const os = require('os')
const imageStream = require('../sdk/image-stream')
const errors = require('../shared/errors')
const constraints = require('../shared/drive-constraints')
const ImageWriter = require('../sdk/writer')
const diskpart = require('./diskpart')
/**
* @summary Timeout, in milliseconds, to wait before unmounting on success
* @constant
* @type {Number}
*/
const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
/**
* @summary Write an image to a disk drive
@ -61,85 +45,18 @@ const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
* });
*/
exports.writeImage = (imagePath, drive, options, onProgress) => {
return drivelist.listAsync().then((drives) => {
const selectedDrive = _.find(drives, {
device: drive
})
const writer = new ImageWriter({
path: drive,
imagePath,
verify: options.validateWriteOnSuccess,
checksumAlgorithms: [ 'crc32' ],
unmountOnSuccess: options.unmountOnSuccess
})
if (!selectedDrive) {
throw errors.createUserError({
title: 'The selected drive was not found',
description: `We can't find ${drive} in your system. Did you unplug the drive?`,
code: 'EUNPLUGGED'
})
}
return selectedDrive
}).then((driveObject) => {
return Bluebird.try(() => {
// Unmounting a drive in Windows means we can't write to it anymore
if (os.platform() === 'win32') {
return Bluebird.resolve()
}
return mountutils.unmountDiskAsync(driveObject.device)
}).then(() => {
return diskpart.clean(driveObject.device)
}).then(() => {
/* eslint-disable no-bitwise */
const flags = fs.constants.O_RDWR |
fs.constants.O_NONBLOCK |
fs.constants.O_SYNC
/* eslint-enable no-bitwise */
return fs.openAsync(driveObject.raw, flags)
}).then((driveFileDescriptor) => {
return imageStream.getFromFilePath(imagePath).then((image) => {
if (!constraints.isDriveLargeEnough(driveObject, image)) {
throw errors.createUserError({
title: 'The image you selected is too big for this drive',
description: 'Please connect a bigger drive and try again'
})
}
const writer = new ImageWriter({
image,
fd: driveFileDescriptor,
path: driveObject.raw,
verify: options.validateWriteOnSuccess,
checksumAlgorithms: [ 'crc32' ]
})
return writer.write()
}).then((writer) => {
return new Bluebird((resolve, reject) => {
writer.on('progress', onProgress)
writer.on('error', reject)
writer.on('finish', (results) => {
results.drive = driveObject
resolve(results)
})
})
}).tap(() => {
// Make sure the device stream file descriptor is closed
// before returning control the the caller. Not closing
// the file descriptor (and waiting for it) results in
// `EBUSY` errors when attempting to unmount the drive
// right afterwards in some Windows 7 systems.
return fs.closeAsync(driveFileDescriptor).then(() => {
if (!options.unmountOnSuccess) {
return Bluebird.resolve()
}
// Closing a file descriptor on a drive containing mountable
// partitions causes macOS to mount the drive. If we try to
// unmount to quickly, then the drive might get re-mounted
// right afterwards.
return Bluebird.delay(UNMOUNT_ON_SUCCESS_TIMEOUT_MS)
.return(driveObject.device)
.then(mountutils.unmountDiskAsync)
})
})
})
return new Bluebird((resolve, reject) => {
writer.flash()
.on('error', reject)
.on('progress', onProgress)
.on('finish', resolve)
})
}

View File

@ -16,6 +16,11 @@
'use strict'
const os = require('os')
const fs = require('fs')
const EventEmitter = require('events').EventEmitter
const mountutils = require('mountutils')
const drivelist = require('drivelist')
const stream = require('readable-stream')
const Pipage = require('pipage')
const BlockMap = require('blockmap')
@ -24,10 +29,54 @@ const BlockWriteStream = require('./block-write-stream')
const BlockReadStream = require('./block-read-stream')
const ChecksumStream = require('./checksum-stream')
const ProgressStream = require('./progress-stream')
const imageStream = require('../image-stream')
const diskpart = require('../../cli/diskpart')
const constraints = require('../../shared/drive-constraints')
const errors = require('../../shared/errors')
const debug = require('debug')('etcher:writer')
const EventEmitter = require('events').EventEmitter
const _ = require('lodash')
/* eslint-disable prefer-reflect */
/**
* @summary Timeout, in milliseconds, to wait before unmounting on success
* @constant
* @type {Number}
*/
const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
/**
* @summary Helper function to run a set of async tasks in sequence
* @private
* @param {Array<Function>} tasks - set of tasks
* @param {Function} callback - callback(error)
* @example
* runSeries([
* (next) => first(next),
* (next) => second(next),
* ], (error) => {
* // ...
* })
*/
const runSeries = (tasks, callback) => {
/**
* @summary Task runner
* @param {Error} [error] - error
* @example
* run()
*/
const run = (error) => {
const task = tasks.shift()
if (error || task == null) {
callback(error)
return
}
task(run)
}
run()
}
/**
* @summary ImageWriter class
* @class
@ -36,6 +85,11 @@ class ImageWriter extends EventEmitter {
/**
* @summary ImageWriter constructor
* @param {Object} options - options
* @param {String} options.imagePath - disk image path
* @param {String} options.path - dest path
* @param {Boolean} options.verify - whether to verify the dest
* @param {Boolean} options.unmountOnSuccess - whether to unmount the dest after flashing
* @param {Array<String>} options.checksumAlgorithms - checksums to calculate
* @example
* new ImageWriter(options)
*/
@ -48,13 +102,187 @@ class ImageWriter extends EventEmitter {
this.pipeline = null
this.target = null
this.hadError = false
this.bytesRead = 0
this.bytesWritten = 0
this.checksum = {}
}
/**
* @summary Verify that the selected destination device exists
* @param {Function} callback - callback(error)
* @private
* @example
* writer.checkSelectedDevice((error) => {
* // ...
* })
*/
checkSelectedDevice (callback) {
debug('state:device-select', this.options.path)
this.destinationDevice = null
drivelist.list((error, drives) => {
debug('state:device-select', this.options.path, error ? 'NOT OK' : 'OK')
if (error) {
callback.call(this, error)
return
}
const selectedDrive = _.find(drives, {
device: this.options.path
})
if (!selectedDrive) {
const selectionError = errors.createUserError({
title: 'The selected drive was not found',
description: `We can't find ${this.options.path} in your system. Did you unplug the drive?`,
code: 'EUNPLUGGED'
})
debug('state:device-select', this.options.path, 'NOT OK')
callback.call(this, selectionError)
return
}
this.destinationDevice = selectedDrive
callback.call(this)
})
}
/**
* @summary Unmount the destination device
* @param {Function} callback - callback(error)
* @private
* @example
* writer.unmountDevice((error) => {
* // ...
* })
*/
unmountDevice (callback) {
if (os.platform() === 'win32') {
callback.call(this)
return
}
debug('state:unmount', this.destinationDevice.device)
mountutils.unmountDisk(this.destinationDevice.device, (error) => {
debug('state:unmount', this.destinationDevice.device, error ? 'NOT OK' : 'OK')
callback.call(this, error)
})
}
/**
* @summary Clean a device's partition table
* @param {Function} callback - callback(error)
* @private
* @example
* writer.removePartitionTable((error) => {
* // ...
* })
*/
removePartitionTable (callback) {
if (os.platform() !== 'win32') {
callback.call(this)
return
}
debug('state:clean', this.destinationDevice.device)
diskpart.clean(this.destinationDevice.device).asCallback((error) => {
debug('state:clean', this.destinationDevice.device, error ? 'NOT OK' : 'OK')
callback.call(this, error)
})
}
/**
* @summary Open the source for reading
* @param {Function} callback - callback(error)
* @private
* @example
* writer.openSource((error) => {
* // ...
* })
*/
openSource (callback) {
debug('state:source-open', this.options.imagePath)
imageStream.getFromFilePath(this.options.imagePath).asCallback((error, image) => {
debug('state:source-open', this.options.imagePath, error ? 'NOT OK' : 'OK')
if (error) {
callback.call(this, error)
return
}
if (!constraints.isDriveLargeEnough(this.destinationDevice, image)) {
const driveError = errors.createUserError({
title: 'The image you selected is too big for this drive',
description: 'Please connect a bigger drive and try again'
})
debug('state:source-open', this.options.imagePath, 'NOT OK')
callback.call(this, driveError)
return
}
this.options.image = image
callback.call(this)
})
}
/**
* @summary Open the destination for writing
* @param {Function} callback - callback(error)
* @private
* @example
* writer.openDestination((error) => {
* // ...
* })
*/
openDestination (callback) {
debug('state:destination-open', this.destinationDevice.raw)
/* eslint-disable no-bitwise */
const flags = fs.constants.O_RDWR |
fs.constants.O_NONBLOCK |
fs.constants.O_SYNC
/* eslint-enable no-bitwise */
fs.open(this.destinationDevice.raw, flags, (error, fd) => {
debug('state:destination-open', this.destinationDevice.raw, error ? 'NOT OK' : 'OK')
this.options.fd = fd
callback.call(this, error)
})
}
/**
* @summary Start the flashing process
* @returns {ImageWriter} imageWriter
* @example
* imageWriter.flash()
* .on('error', reject)
* .on('progress', onProgress)
* .on('finish', resolve)
*/
flash () {
const tasks = [
(next) => { this.checkSelectedDevice(next) },
(next) => { this.unmountDevice(next) },
(next) => { this.removePartitionTable(next) },
(next) => { this.openSource(next) },
(next) => { this.openDestination(next) }
]
runSeries(tasks, (error) => {
if (error) {
this.emit('error')
return
}
this.write()
})
return this
}
/**
* @summary Start the writing process
* @returns {ImageWriter} imageWriter
@ -62,15 +290,12 @@ class ImageWriter extends EventEmitter {
* imageWriter.write()
*/
write () {
this.hadError = false
this._createWritePipeline(this.options)
.on('checksum', (checksum) => {
debug('write:checksum', checksum)
this.checksum = checksum
})
.on('error', (error) => {
this.hadError = true
this.emit('error', error)
})
@ -80,7 +305,7 @@ class ImageWriter extends EventEmitter {
if (this.options.verify) {
this.verify()
} else {
this._emitFinish()
this._finish()
}
})
@ -96,7 +321,6 @@ class ImageWriter extends EventEmitter {
verify () {
this._createVerifyPipeline(this.options)
.on('error', (error) => {
this.hadError = true
this.emit('error', error)
})
.on('checksum', (checksum) => {
@ -106,13 +330,13 @@ class ImageWriter extends EventEmitter {
error.code = 'EVALIDATION'
this.emit('error', error)
}
this._emitFinish()
this._finish()
})
.on('finish', () => {
debug('verify:end')
// NOTE: As the 'checksum' event only happens after
// the 'finish' event, we `._emitFinish()` there instead of here
// the 'finish' event, we `._finish()` there instead of here
})
return this
@ -125,22 +349,56 @@ class ImageWriter extends EventEmitter {
*/
abort () {
if (this.source) {
this.emit('abort')
this.source.destroy()
}
this.emit('abort')
}
/**
* @summary Cleanup after writing; close file descriptors & unmount
* @param {Function} callback - callback(error)
* @private
* @example
* writer._cleanup((error) => {
* // ...
* })
*/
_cleanup (callback) {
debug('state:cleanup')
fs.close(this.options.fd, (closeError) => {
debug('state:cleanup', closeError ? 'NOT OK' : 'OK')
if (!this.options.unmountOnSuccess) {
callback.call(this, closeError)
return
}
// Closing a file descriptor on a drive containing mountable
// partitions causes macOS to mount the drive. If we try to
// unmount too quickly, then the drive might get re-mounted
// right afterwards.
setTimeout(() => {
mountutils.unmountDisk(this.destinationDevice.device, (error) => {
debug('state:cleanup', error ? 'NOT OK' : 'OK')
callback.call(this, error)
})
}, UNMOUNT_ON_SUCCESS_TIMEOUT_MS)
})
}
/**
* @summary Emits the `finish` event with state metadata
* @private
* @example
* this._emitFinish()
* this._finish()
*/
_emitFinish () {
this.emit('finish', {
bytesRead: this.bytesRead,
bytesWritten: this.bytesWritten,
checksum: this.checksum
_finish () {
this._cleanup(() => {
this.emit('finish', {
drive: this.destinationDevice,
bytesRead: this.bytesRead,
bytesWritten: this.bytesWritten,
checksum: this.checksum
})
})
}
@ -216,9 +474,6 @@ class ImageWriter extends EventEmitter {
const target = new BlockWriteStream({
fd: options.fd,
path: options.path,
flags: options.flags,
mode: options.mode,
autoClose: false
})
@ -296,9 +551,6 @@ class ImageWriter extends EventEmitter {
const source = new BlockReadStream({
fd: options.fd,
path: options.path,
flags: options.flags,
mode: options.mode,
autoClose: false,
start: 0,
end: size