import * as stream from 'stream'; import { AssertionError, expect } from 'chai'; import { stub } from 'sinon'; import * as tmp from 'tmp'; import { delay } from '../../utils'; import * as fs from 'fs'; import * as fsAsync from 'fs/promises'; import * as stringToStream from 'string-to-stream'; import { Writable as WritableStream } from 'stream'; import * as imageManager from '../../../build/utils/image-manager'; import { resolve, extname } from 'path'; import * as mockFs from 'mock-fs'; import * as rimraf from 'rimraf'; import { promisify } from 'util'; import * as os from 'os'; // Make sure we're all using literally the same instance of balena-sdk // so we can mock out methods called by the real code import { getBalenaSdk } from '../../../build/utils/lazy'; const balena = getBalenaSdk(); const fsExistsAsync = promisify(fs.exists); const clean = async () => { await promisify(rimraf)(await balena.settings.get('cacheDirectory')); }; describe('image-manager', function () { describe('.getStream()', () => { describe('given the existing image', function () { beforeEach(function () { this.image = tmp.fileSync(); fs.writeSync(this.image.fd, 'Cache image', 0, 'utf8'); this.cacheGetImagePathStub = stub(imageManager, 'getImagePath'); return this.cacheGetImagePathStub.returns( Promise.resolve(this.image.name), ); }); afterEach(function () { this.cacheGetImagePathStub.restore(); return this.image.removeCallback(); }); describe('given the image is fresh', function () { beforeEach(function () { this.cacheIsImageFresh = stub(imageManager, 'isImageFresh'); return this.cacheIsImageFresh.returns(Promise.resolve(true)); }); afterEach(function () { return this.cacheIsImageFresh.restore(); }); it('should eventually become a readable stream of the cached image', function (done) { this.timeout(5000); void imageManager.getStream('raspberry-pi').then(function (stream) { let result = ''; stream.on('data', (chunk) => (result += chunk.toString())); return stream.on('end', function () { expect(result).to.equal('Cache image'); return done(); }); }); }); }); describe('given the image is not fresh', function () { beforeEach(function () { this.cacheIsImageFresh = stub(imageManager, 'isImageFresh'); return this.cacheIsImageFresh.returns(Promise.resolve(false)); }); afterEach(function () { return this.cacheIsImageFresh.restore(); }); // Skipping test because we keep getting `Cache image` instead of `Download image` describe.skip('given a valid download endpoint', function () { beforeEach(function () { this.osDownloadStub = stub(balena.models.os, 'download'); this.osDownloadStub.returns( Promise.resolve(stringToStream('Download image')), ); }); afterEach(function () { this.osDownloadStub.restore(); }); it('should eventually become a readable stream of the download image and save a backup copy', function (done) { void imageManager.getStream('raspberry-pi').then((stream) => { let result = ''; stream.on('data', (chunk) => (result += chunk)); stream.on('end', async () => { expect(result).to.equal('Download image'); const contents = await fsAsync.readFile(this.image.name, { encoding: 'utf8', }); expect(contents).to.equal('Download image'); done(); }); }); }); it('should be able to read from the stream after a slight delay', function (done) { void imageManager.getStream('raspberry-pi').then(async (s) => { await delay(200); const pass = new stream.PassThrough(); s.pipe(pass); let result = ''; pass.on('data', (chunk) => (result += chunk)); pass.on('end', function () { expect(result).to.equal('Download image'); done(); }); }); }); }); describe('given a failing download', function () { beforeEach(function () { this.osDownloadStream = new stream.PassThrough(); this.osDownloadStub = stub(balena.models.os, 'download'); this.osDownloadStub.returns(Promise.resolve(this.osDownloadStream)); }); afterEach(function () { this.osDownloadStub.restore(); }); it('should clean up the in progress cached stream if an error occurs', function (done) { if (os.platform() === 'win32') { // Skipping test on Windows because we get `EPERM: operation not permitted, rename` for `getImageWritableStream` on the windows runner this.skip(); } void imageManager.getStream('raspberry-pi').then((stream) => { stream.on('data', () => { // After the first chunk, error return this.osDownloadStream.emit('error'); }); stream.on('error', async () => { const contents = await fsAsync .stat(this.image.name + '.inprogress') .then(function () { throw new AssertionError( 'Image cache should be deleted on failure', ); }) .catch((err) => { if (err.code !== 'ENOENT') { throw err; } return fsAsync.readFile(this.image.name, { encoding: 'utf8', }); }); expect(contents).to.equal('Cache image'); done(); }); stringToStream('Download image').pipe(this.osDownloadStream); }); }); }); describe('given a stream with the mime property', async function () { beforeEach(function () { this.osDownloadStub = stub(balena.models.os, 'download'); const message = 'Lorem ipsum dolor sit amet'; const mockResultStream = stringToStream(message) as ReturnType< typeof stringToStream > & { mime?: string; }; mockResultStream.mime = 'application/zip'; this.osDownloadStub.returns(Promise.resolve(mockResultStream)); }); afterEach(function () { this.osDownloadStub.restore(); }); it('should preserve the property', () => imageManager .getStream('raspberry-pi') .then((resultStream) => expect(resultStream.mime).to.equal('application/zip'), )); }); }); }); }); describe('.getImagePath()', () => { describe('given a cache directory', function () { beforeEach(function () { this.balenaSettingsGetStub = stub(balena.settings, 'get'); this.balenaSettingsGetStub .withArgs('cacheDirectory') .returns( Promise.resolve( os.platform() === 'win32' ? 'C:\\Users\\johndoe\\_balena\\cache' : '/Users/johndoe/.balena/cache', ), ); }); afterEach(function () { this.balenaSettingsGetStub.restore(); }); describe('given valid slugs', function () { beforeEach(function () { this.getDeviceTypeManifestBySlugStub = stub( balena.models.config, 'getDeviceTypeManifestBySlug', ); this.getDeviceTypeManifestBySlugStub.withArgs('raspberry-pi').returns( Promise.resolve({ yocto: { fstype: 'resin-sdcard', }, }), ); this.getDeviceTypeManifestBySlugStub.withArgs('intel-edison').returns( Promise.resolve({ yocto: { fstype: 'zip', }, }), ); }); afterEach(function () { this.getDeviceTypeManifestBySlugStub.restore(); }); it('should eventually equal an absolute path', async () => { await imageManager .getImagePath('raspberry-pi', '1.2.3') .then(function (imagePath) { const isAbsolute = imagePath === resolve(imagePath); expect(isAbsolute).to.be.true; }); }); it('should eventually equal the correct path', async function () { const result = await imageManager.getImagePath( 'raspberry-pi', '1.2.3', ); expect(result).to.equal( os.platform() === 'win32' ? 'C:\\Users\\johndoe\\_balena\\cache\\raspberry-pi-v1.2.3.img' : '/Users/johndoe/.balena/cache/raspberry-pi-v1.2.3.img', ); }); it('should use a zip extension for directory images', async () => { const imagePath = await imageManager.getImagePath( 'intel-edison', '1.2.3', ); expect(extname(imagePath)).to.equal('.zip'); }); it('given invalid version should be rejected', async function () { const promise = imageManager.getImagePath('intel-edison', 'DOUGH'); await expect(promise).to.be.eventually.rejectedWith( 'Invalid version number', ); }); }); }); }); describe('.isImageFresh()', () => { describe('given the raspberry-pi manifest', function () { beforeEach(function () { this.getDeviceTypeManifestBySlugStub = stub( balena.models.config, 'getDeviceTypeManifestBySlug', ); this.getDeviceTypeManifestBySlugStub.returns( Promise.resolve({ yocto: { fstype: 'balena-sdcard', }, }), ); }); afterEach(function () { this.getDeviceTypeManifestBySlugStub.restore(); }); describe('given the file does not exist', function () { beforeEach(function () { this.utilsGetFileCreatedDate = stub( imageManager, 'getFileCreatedDate', ); this.utilsGetFileCreatedDate.returns( Promise.reject(new Error("ENOENT, stat 'raspberry-pi'")), ); }); afterEach(function () { this.utilsGetFileCreatedDate.restore(); }); it('should return false', async function () { expect(await imageManager.isImageFresh('raspberry-pi', '1.2.3')).to.be .false; }); }); describe('given a fixed created time', function () { beforeEach(function () { this.utilsGetFileCreatedDate = stub( imageManager, 'getFileCreatedDate', ); this.utilsGetFileCreatedDate.returns( Promise.resolve(new Date('2014-01-01T00:00:00.000Z')), ); }); afterEach(function () { this.utilsGetFileCreatedDate.restore(); }); describe('given the file was created before the os last modified time', function () { beforeEach(function () { this.osGetLastModified = stub(balena.models.os, 'getLastModified'); this.osGetLastModified.returns( Promise.resolve(new Date('2014-02-01T00:00:00.000Z')), ); }); afterEach(function () { this.osGetLastModified.restore(); }); it('should return false', function () { const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3'); return expect(promise).to.eventually.be.false; }); }); describe('given the file was created after the os last modified time', function () { beforeEach(function () { this.osGetLastModified = stub(balena.models.os, 'getLastModified'); this.osGetLastModified.returns( Promise.resolve(new Date('2013-01-01T00:00:00.000Z')), ); }); afterEach(function () { this.osGetLastModified.restore(); }); it('should return true', function () { const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3'); return expect(promise).to.eventually.be.true; }); }); describe('given the file was created just at the os last modified time', function () { beforeEach(function () { this.osGetLastModified = stub(balena.models.os, 'getLastModified'); this.osGetLastModified.returns( Promise.resolve(new Date('2014-00-01T00:00:00.000Z')), ); }); afterEach(function () { this.osGetLastModified.restore(); }); it('should return false', function () { const promise = imageManager.isImageFresh('raspberry-pi', '1.2.3'); return expect(promise).to.eventually.be.false; }); }); }); }); }); describe('.getImage()', () => { describe('given an existing image', function () { beforeEach(function () { this.image = tmp.fileSync(); fs.writeSync(this.image.fd, 'Lorem ipsum dolor sit amet', 0, 'utf8'); this.cacheGetImagePathStub = stub(imageManager, 'getImagePath'); this.cacheGetImagePathStub.returns(Promise.resolve(this.image.name)); }); afterEach(function (done) { this.cacheGetImagePathStub.restore(); fs.unlink(this.image.name, done); }); it('should return a stream to the image', function (done) { void imageManager .getImage('lorem-ipsum', '1.2.3') .then(function (stream) { let result = ''; stream.on('data', (chunk) => (result += chunk)); stream.on('end', function () { expect(result).to.equal('Lorem ipsum dolor sit amet'); done(); }); }); }); it('should contain the mime property', () => imageManager .getImage('lorem-ipsum', '1.2.3') .then((stream) => expect(stream.mime).to.equal('application/octet-stream'), )); }); }); describe('.getImageWritableStream()', () => { describe('given the valid image path', function () { beforeEach(function () { this.image = tmp.fileSync(); this.cacheGetImagePathStub = stub(imageManager, 'getImagePath'); this.cacheGetImagePathStub.returns(Promise.resolve(this.image.name)); }); afterEach(function (done) { this.cacheGetImagePathStub.restore(); fs.unlink(this.image.name, done); }); it('should return a writable stream', () => imageManager .getImageWritableStream('raspberry-pi', '1.2.3') .then((stream) => expect(stream).to.be.an.instanceof(WritableStream), )); it('should allow writing to the stream', function (done) { if (os.platform() === 'win32') { // Skipping test on Windows because we get `EPERM: operation not permitted, rename` for `getImageWritableStream` on the windows runner this.skip(); } void imageManager .getImageWritableStream('raspberry-pi', '1.2.3') .then((stream) => { const stringStream = stringToStream('Lorem ipsum dolor sit amet'); stringStream.pipe(stream); stream.on('finish', async () => { await stream.persistCache(); const contents = await fsAsync.readFile(this.image.name, { encoding: 'utf8', }); expect(contents).to.equal('Lorem ipsum dolor sit amet'); done(); }); }); }); }); }); describe('.getFileCreatedDate()', function () { describe('given the file exists', function () { beforeEach(function () { this.date = new Date(2014, 1, 1); this.fsStatStub = stub(fs.promises, 'stat'); this.fsStatStub .withArgs('foo') .returns(Promise.resolve({ ctime: this.date })); }); afterEach(function () { this.fsStatStub.restore(); }); it('should eventually equal the created time in milliseconds', async function () { const promise = imageManager.getFileCreatedDate('foo'); await expect(promise).to.eventually.equal(this.date); }); }); describe('given the file does not exist', function () { beforeEach(function () { this.fsStatStub = stub(fs.promises, 'stat'); this.fsStatStub .withArgs('foo') .returns(Promise.reject(new Error("ENOENT, stat 'foo'"))); }); afterEach(function () { this.fsStatStub.restore(); }); it('should be rejected with an error', async function () { const promise = imageManager.getFileCreatedDate('foo'); await expect(promise).to.be.rejectedWith('ENOENT'); }); }); }); describe('.clean()', function () { describe('given the cache with saved images', function () { beforeEach(async function () { this.cacheDirectory = await balena.settings.get('cacheDirectory'); mockFs({ [this.cacheDirectory]: { 'raspberry-pi': 'Raspberry Pi Image', 'intel-edison': 'Intel Edison Image', parallela: 'Parallela Image', }, }); }); afterEach(() => { mockFs.restore(); }); it('should remove the cache directory completely', async function () { const exists = await fsExistsAsync(this.cacheDirectory); expect(exists).to.be.true; await clean(); const exists2 = await fsExistsAsync(this.cacheDirectory); expect(exists2).to.be.false; }); }); describe('given no cache', function () { beforeEach(async function () { this.cacheDirectory = await balena.settings.get('cacheDirectory'); mockFs({}); }); afterEach(() => mockFs.restore()); it('should keep the cache directory removed', async function () { const exists = await fsExistsAsync(this.cacheDirectory); expect(exists).to.be.false; await clean(); const exists2 = await fsExistsAsync(this.cacheDirectory); expect(exists2).to.be.false; }); }); }); });