mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-23 07:22:22 +00:00
77b9514442
Change-type: patch
569 lines
16 KiB
TypeScript
569 lines
16 KiB
TypeScript
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;
|
|
});
|
|
});
|
|
});
|
|
});
|