mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-18 18:56:24 +00:00
Add support for BALENA_HOST_SPLASH_IMAGE
config
Setting this this variable to a base64 encoded string will replace the splash image on the device by rewriting `/mnt/boot/splash/balena-logo.png`. This will also make a copy of the default balena logo so the splash can be restored if the variable is removed. Change-type: minor Signed-off-by: Felipe Lalanne <felipe@balena.io>
This commit is contained in:
parent
e66a775c15
commit
4aa8090a56
@ -12,7 +12,7 @@ export const bootMountPoint = `${constants.rootMountPoint}${constants.bootMountP
|
|||||||
|
|
||||||
export async function remountAndWriteAtomic(
|
export async function remountAndWriteAtomic(
|
||||||
file: string,
|
file: string,
|
||||||
data: string,
|
data: string | Buffer,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Here's the dangerous part:
|
// Here's the dangerous part:
|
||||||
await child_process.exec(
|
await child_process.exec(
|
||||||
|
@ -5,6 +5,7 @@ import { ExtraUEnv } from './extra-uEnv';
|
|||||||
import { ConfigTxt } from './config-txt';
|
import { ConfigTxt } from './config-txt';
|
||||||
import { ConfigFs } from './config-fs';
|
import { ConfigFs } from './config-fs';
|
||||||
import { Odmdata } from './odmdata';
|
import { Odmdata } from './odmdata';
|
||||||
|
import { SplashImage } from './splash-image';
|
||||||
|
|
||||||
export const allBackends = [
|
export const allBackends = [
|
||||||
new Extlinux(),
|
new Extlinux(),
|
||||||
@ -12,6 +13,7 @@ export const allBackends = [
|
|||||||
new ConfigTxt(),
|
new ConfigTxt(),
|
||||||
new ConfigFs(),
|
new ConfigFs(),
|
||||||
new Odmdata(),
|
new Odmdata(),
|
||||||
|
new SplashImage(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function matchesAnyBootConfig(envVar: string): boolean {
|
export function matchesAnyBootConfig(envVar: string): boolean {
|
||||||
|
206
src/config/backends/splash-image.ts
Normal file
206
src/config/backends/splash-image.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import * as Bluebird from 'bluebird';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import { fs } from 'mz';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import * as constants from '../../lib/constants';
|
||||||
|
import log from '../../lib/supervisor-console';
|
||||||
|
import {
|
||||||
|
bootMountPoint,
|
||||||
|
ConfigBackend,
|
||||||
|
ConfigOptions,
|
||||||
|
remountAndWriteAtomic,
|
||||||
|
} from './backend';
|
||||||
|
|
||||||
|
export class SplashImage extends ConfigBackend {
|
||||||
|
private static readonly BASEPATH = path.join(bootMountPoint, 'splash');
|
||||||
|
private static readonly DEFAULT = path.join(
|
||||||
|
SplashImage.BASEPATH,
|
||||||
|
'balena-logo-default.png',
|
||||||
|
);
|
||||||
|
private static readonly FILENAMES = ['balena-logo.png', 'resin-logo.png'];
|
||||||
|
private static readonly PREFIX = `${constants.hostConfigVarPrefix}SPLASH_`;
|
||||||
|
private static readonly CONFIGS = ['image'];
|
||||||
|
private static readonly DATA_URI_REGEX = /^data:(.+);base64,(.*)$/;
|
||||||
|
|
||||||
|
// Check the first 8 bytes of a buffer for a PNG header
|
||||||
|
// Source: https://github.com/sindresorhus/is-png/blob/master/index.js
|
||||||
|
private isPng(buffer: Buffer) {
|
||||||
|
if (!buffer || buffer.length < 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer[1] === 0x50 &&
|
||||||
|
buffer[2] === 0x4e &&
|
||||||
|
buffer[3] === 0x47 &&
|
||||||
|
buffer[4] === 0x0d &&
|
||||||
|
buffer[5] === 0x0a &&
|
||||||
|
buffer[6] === 0x1a &&
|
||||||
|
buffer[7] === 0x0a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file path for the splash image for the underlying
|
||||||
|
// system
|
||||||
|
private async getSplashPath(): Promise<string> {
|
||||||
|
// TODO: this is not perfect, if the file was supposed to be
|
||||||
|
// resin-logo.png and for some reason is not present on the folder
|
||||||
|
// the supervisor will try to write new images from the API into
|
||||||
|
// balena-logo.png to no effect. Ideally this should be configurable
|
||||||
|
// as a constant that is provided on supervisor container launch.
|
||||||
|
const [
|
||||||
|
// If no logo is found, assume the file is `balena-logo.png`
|
||||||
|
splashFile = path.join(SplashImage.BASEPATH, 'balena-logo.png'),
|
||||||
|
] = (
|
||||||
|
await Bluebird.resolve(fs.readdir(SplashImage.BASEPATH))
|
||||||
|
// Read the splash dir (will throw if the path does not exist)
|
||||||
|
// And filter valid filenames
|
||||||
|
.filter((filename) => SplashImage.FILENAMES.includes(filename))
|
||||||
|
)
|
||||||
|
// Sort by name, so in case both files are defined, balena-logo will
|
||||||
|
// be chosen
|
||||||
|
.sort()
|
||||||
|
// Convert to full path
|
||||||
|
.map((filename) => path.join(SplashImage.BASEPATH, filename));
|
||||||
|
|
||||||
|
return splashFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the base64 contents of the splash image
|
||||||
|
private async readSplashImage(where?: string): Promise<string> {
|
||||||
|
// Read from defaultPath unless where is defined
|
||||||
|
where = where ?? (await this.getSplashPath());
|
||||||
|
|
||||||
|
// read the image file...
|
||||||
|
return (await fs.readFile(where)).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a splash image provided as a base64 string
|
||||||
|
private async writeSplashImage(image: string, where?: string): Promise<void> {
|
||||||
|
// Write to defaultPath unless where is defined
|
||||||
|
where = where ?? (await this.getSplashPath());
|
||||||
|
|
||||||
|
const buffer = Buffer.from(image, 'base64');
|
||||||
|
if (this.isPng(buffer)) {
|
||||||
|
// Write the buffer to the given location
|
||||||
|
await remountAndWriteAtomic(where, buffer);
|
||||||
|
} else {
|
||||||
|
throw new Error('Splash image should be a base64 encoded PNG image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripPrefix(name: string): string {
|
||||||
|
if (!name.startsWith(SplashImage.PREFIX)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return name.substr(SplashImage.PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public createConfigVarName(name: string): string {
|
||||||
|
return `${SplashImage.PREFIX}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBootConfig(): Promise<ConfigOptions> {
|
||||||
|
try {
|
||||||
|
const defaultImg = await this.readSplashImage(SplashImage.DEFAULT);
|
||||||
|
const img = await this.readSplashImage();
|
||||||
|
|
||||||
|
// If the image is the same as the default image
|
||||||
|
// return nothing
|
||||||
|
if (img !== defaultImg) {
|
||||||
|
return {
|
||||||
|
image: `data:image/png;base64,${img}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('Failed to read splash image:', e);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialise(): Promise<SplashImage> {
|
||||||
|
try {
|
||||||
|
await super.initialise();
|
||||||
|
|
||||||
|
// The default boot image file has already
|
||||||
|
// been created
|
||||||
|
if (await fs.exists(SplashImage.DEFAULT)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// read the existing image file...
|
||||||
|
const image = await this.readSplashImage();
|
||||||
|
|
||||||
|
// write the image to the DEFAULT path
|
||||||
|
await this.writeSplashImage(image, SplashImage.DEFAULT);
|
||||||
|
|
||||||
|
log.success('Initialised splash image backend');
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Could not initialise splash image backend', error);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ensureRequiredConfig(_deviceType: string, conf: ConfigOptions) {
|
||||||
|
// If the value from the cloud is empty, it is the same as no definition
|
||||||
|
if (
|
||||||
|
!_.isUndefined(conf.image) &&
|
||||||
|
_.isEmpty((conf.image as string).trim())
|
||||||
|
) {
|
||||||
|
delete conf.image;
|
||||||
|
}
|
||||||
|
return conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSupportedConfig(name: string): boolean {
|
||||||
|
return SplashImage.CONFIGS.includes(this.stripPrefix(name).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
public isBootConfigVar(name: string): boolean {
|
||||||
|
return SplashImage.CONFIGS.includes(this.stripPrefix(name).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async matches(_deviceType: string): Promise<boolean> {
|
||||||
|
// all device types
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public processConfigVarName(name: string): string {
|
||||||
|
return this.stripPrefix(name).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public processConfigVarValue(
|
||||||
|
_name: string,
|
||||||
|
value: string,
|
||||||
|
): string | string[] {
|
||||||
|
// check data url regex
|
||||||
|
const matches = value.match(SplashImage.DATA_URI_REGEX);
|
||||||
|
if (!_.isNull(matches)) {
|
||||||
|
const [, media, data] = matches;
|
||||||
|
const [type] = media.split(';'); // discard mediatype parameters
|
||||||
|
|
||||||
|
return `data:${type};base64,${data}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_.isEmpty(value.trim())) {
|
||||||
|
// Assume data is base64 encoded. If is not, setBootConfig will fail
|
||||||
|
return `data:image/png;base64,${value}`;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||||
|
// If no splash image is defined, revert to the default splash image
|
||||||
|
const value = !_.isEmpty(opts.image)
|
||||||
|
? (opts.image as string)
|
||||||
|
: await this.readSplashImage(SplashImage.DEFAULT);
|
||||||
|
|
||||||
|
// If it is a data URI get only the data part
|
||||||
|
const [, image] = value.startsWith('data:') ? value.split(',') : [, value];
|
||||||
|
|
||||||
|
// Rewrite the splash image
|
||||||
|
await this.writeSplashImage(image);
|
||||||
|
}
|
||||||
|
}
|
@ -321,9 +321,11 @@ export function formatConfigKeys(conf: {
|
|||||||
confFromLegacyNamespace,
|
confFromLegacyNamespace,
|
||||||
noNamespaceConf,
|
noNamespaceConf,
|
||||||
);
|
);
|
||||||
return _.pickBy(confWithoutNamespace, (_v, k) => {
|
|
||||||
return _.includes(validKeys, k) || matchesAnyBootConfig(k);
|
return _.pickBy(
|
||||||
});
|
confWithoutNamespace,
|
||||||
|
(_v, k) => _.includes(validKeys, k) || matchesAnyBootConfig(k),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaults() {
|
export function getDefaults() {
|
||||||
|
@ -1,18 +1,28 @@
|
|||||||
import * as Bluebird from 'bluebird';
|
import * as Bluebird from 'bluebird';
|
||||||
|
import * as _ from 'lodash';
|
||||||
import { fs } from 'mz';
|
import { fs } from 'mz';
|
||||||
import * as Path from 'path';
|
import * as Path from 'path';
|
||||||
import * as constants from './constants';
|
import * as constants from './constants';
|
||||||
import { ENOENT } from './errors';
|
import { ENOENT } from './errors';
|
||||||
|
|
||||||
export function writeAndSyncFile(path: string, data: string): Bluebird<void> {
|
export function writeAndSyncFile(
|
||||||
|
path: string,
|
||||||
|
data: string | Buffer,
|
||||||
|
): Bluebird<void> {
|
||||||
return Bluebird.resolve(fs.open(path, 'w')).then((fd) => {
|
return Bluebird.resolve(fs.open(path, 'w')).then((fd) => {
|
||||||
fs.write(fd, data, 0, 'utf8')
|
_.isString(data)
|
||||||
.then(() => fs.fsync(fd))
|
? fs.write(fd, data, 0, 'utf8')
|
||||||
.then(() => fs.close(fd));
|
: fs
|
||||||
|
.write(fd, data, 0, data.length)
|
||||||
|
.then(() => fs.fsync(fd))
|
||||||
|
.then(() => fs.close(fd));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeFileAtomic(path: string, data: string): Bluebird<void> {
|
export function writeFileAtomic(
|
||||||
|
path: string,
|
||||||
|
data: string | Buffer,
|
||||||
|
): Bluebird<void> {
|
||||||
return Bluebird.resolve(writeAndSyncFile(`${path}.new`, data)).then(() =>
|
return Bluebird.resolve(writeAndSyncFile(`${path}.new`, data)).then(() =>
|
||||||
fs.rename(`${path}.new`, path),
|
fs.rename(`${path}.new`, path),
|
||||||
);
|
);
|
||||||
|
@ -10,6 +10,7 @@ import { Extlinux } from '../src/config/backends/extlinux';
|
|||||||
import { ConfigTxt } from '../src/config/backends/config-txt';
|
import { ConfigTxt } from '../src/config/backends/config-txt';
|
||||||
import { Odmdata } from '../src/config/backends/odmdata';
|
import { Odmdata } from '../src/config/backends/odmdata';
|
||||||
import { ConfigFs } from '../src/config/backends/config-fs';
|
import { ConfigFs } from '../src/config/backends/config-fs';
|
||||||
|
import { SplashImage } from '../src/config/backends/splash-image';
|
||||||
import * as constants from '../src/lib/constants';
|
import * as constants from '../src/lib/constants';
|
||||||
import * as config from '../src/config';
|
import * as config from '../src/config';
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ const extlinuxBackend = new Extlinux();
|
|||||||
const configTxtBackend = new ConfigTxt();
|
const configTxtBackend = new ConfigTxt();
|
||||||
const odmdataBackend = new Odmdata();
|
const odmdataBackend = new Odmdata();
|
||||||
const configFsBackend = new ConfigFs();
|
const configFsBackend = new ConfigFs();
|
||||||
|
const splashImageBackend = new SplashImage();
|
||||||
|
|
||||||
describe('Device Backend Config', () => {
|
describe('Device Backend Config', () => {
|
||||||
let logSpy: SinonSpy;
|
let logSpy: SinonSpy;
|
||||||
@ -524,4 +526,151 @@ describe('Device Backend Config', () => {
|
|||||||
).to.equal(false);
|
).to.equal(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Boot splash image', () => {
|
||||||
|
const defaultLogo =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/wQDLA+84AAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==';
|
||||||
|
const png =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=';
|
||||||
|
const uri = `data:image/png;base64,${png}`;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup stubs
|
||||||
|
stub(fsUtils, 'writeFileAtomic').resolves();
|
||||||
|
stub(child_process, 'exec').resolves();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore stubs
|
||||||
|
(fsUtils.writeFileAtomic as SinonStub).restore();
|
||||||
|
(child_process.exec as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly write to resin-logo.png', async () => {
|
||||||
|
// Devices with balenaOS < 2.51 use resin-logo.png
|
||||||
|
stub(fs, 'readdir').resolves(['resin-logo.png']);
|
||||||
|
|
||||||
|
const current = {};
|
||||||
|
const target = {
|
||||||
|
HOST_SPLASH_image: png,
|
||||||
|
};
|
||||||
|
|
||||||
|
// This should work with every device type, but testing on a couple
|
||||||
|
// of options
|
||||||
|
expect(
|
||||||
|
deviceConfig.bootConfigChangeRequired(
|
||||||
|
splashImageBackend,
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
'fincm3',
|
||||||
|
),
|
||||||
|
).to.equal(true);
|
||||||
|
|
||||||
|
await deviceConfig.setBootConfig(splashImageBackend, target);
|
||||||
|
|
||||||
|
expect(child_process.exec).to.be.calledOnce;
|
||||||
|
expect(logSpy).to.be.calledTwice;
|
||||||
|
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
|
||||||
|
expect(fsUtils.writeFileAtomic).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/resin-logo.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
// restore the stub
|
||||||
|
(fs.readdir as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly write to balena-logo.png', async () => {
|
||||||
|
// Devices with balenaOS >= 2.51 use balena-logo.png
|
||||||
|
stub(fs, 'readdir').resolves(['balena-logo.png']);
|
||||||
|
|
||||||
|
const current = {};
|
||||||
|
const target = {
|
||||||
|
HOST_SPLASH_image: png,
|
||||||
|
};
|
||||||
|
|
||||||
|
// This should work with every device type, but testing on a couple
|
||||||
|
// of options
|
||||||
|
expect(
|
||||||
|
deviceConfig.bootConfigChangeRequired(
|
||||||
|
splashImageBackend,
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
'raspberrypi4-64',
|
||||||
|
),
|
||||||
|
).to.equal(true);
|
||||||
|
|
||||||
|
await deviceConfig.setBootConfig(splashImageBackend, target);
|
||||||
|
|
||||||
|
expect(child_process.exec).to.be.calledOnce;
|
||||||
|
expect(logSpy).to.be.calledTwice;
|
||||||
|
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
|
||||||
|
expect(fsUtils.writeFileAtomic).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
// restore the stub
|
||||||
|
(fs.readdir as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly write to balena-logo.png if no default logo is found', async () => {
|
||||||
|
// Devices with balenaOS >= 2.51 use balena-logo.png
|
||||||
|
stub(fs, 'readdir').resolves([]);
|
||||||
|
|
||||||
|
const current = {};
|
||||||
|
const target = {
|
||||||
|
HOST_SPLASH_image: png,
|
||||||
|
};
|
||||||
|
|
||||||
|
// This should work with every device type, but testing on a couple
|
||||||
|
// of options
|
||||||
|
expect(
|
||||||
|
deviceConfig.bootConfigChangeRequired(
|
||||||
|
splashImageBackend,
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
'raspberrypi3',
|
||||||
|
),
|
||||||
|
).to.equal(true);
|
||||||
|
|
||||||
|
await deviceConfig.setBootConfig(splashImageBackend, target);
|
||||||
|
|
||||||
|
expect(child_process.exec).to.be.calledOnce;
|
||||||
|
expect(logSpy).to.be.calledTwice;
|
||||||
|
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
|
||||||
|
expect(fsUtils.writeFileAtomic).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
// restore the stub
|
||||||
|
(fs.readdir as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly read the splash logo if different from the default', async () => {
|
||||||
|
stub(fs, 'readdir').resolves(['balena-logo.png']);
|
||||||
|
|
||||||
|
const readFileStub: SinonStub = stub(fs, 'readFile').resolves(
|
||||||
|
Buffer.from(png, 'base64') as any,
|
||||||
|
);
|
||||||
|
readFileStub
|
||||||
|
.withArgs('test/data/mnt/boot/splash/balena-logo-default.png')
|
||||||
|
.resolves(Buffer.from(defaultLogo, 'base64') as any);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await deviceConfig.getBootConfig(splashImageBackend),
|
||||||
|
).to.deep.equal({
|
||||||
|
HOST_SPLASH_image: uri,
|
||||||
|
});
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore stubs
|
||||||
|
(fs.readdir as SinonStub).restore();
|
||||||
|
(fs.readFile as SinonStub).restore();
|
||||||
|
readFileStub.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,6 +8,7 @@ import { ExtraUEnv } from '../src/config/backends/extra-uEnv';
|
|||||||
import { Extlinux } from '../src/config/backends/extlinux';
|
import { Extlinux } from '../src/config/backends/extlinux';
|
||||||
import { ConfigTxt } from '../src/config/backends/config-txt';
|
import { ConfigTxt } from '../src/config/backends/config-txt';
|
||||||
import { ConfigFs } from '../src/config/backends/config-fs';
|
import { ConfigFs } from '../src/config/backends/config-fs';
|
||||||
|
import { SplashImage } from '../src/config/backends/splash-image';
|
||||||
import { ConfigBackend } from '../src/config/backends/backend';
|
import { ConfigBackend } from '../src/config/backends/backend';
|
||||||
|
|
||||||
describe('Config Utilities', () => {
|
describe('Config Utilities', () => {
|
||||||
@ -16,8 +17,9 @@ describe('Config Utilities', () => {
|
|||||||
const configStub = stub(config, 'get').resolves('raspberry');
|
const configStub = stub(config, 'get').resolves('raspberry');
|
||||||
// Get list of backends
|
// Get list of backends
|
||||||
const devices = await configUtils.getSupportedBackends();
|
const devices = await configUtils.getSupportedBackends();
|
||||||
expect(devices.length).to.equal(1);
|
expect(devices.length).to.equal(2);
|
||||||
expect(devices[0].constructor.name).to.equal('ConfigTxt');
|
expect(devices[0].constructor.name).to.equal('ConfigTxt');
|
||||||
|
expect(devices[1].constructor.name).to.equal('SplashImage');
|
||||||
// Restore stub
|
// Restore stub
|
||||||
configStub.restore();
|
configStub.restore();
|
||||||
// TO-DO: When we have a device that will match for multiple backends
|
// TO-DO: When we have a device that will match for multiple backends
|
||||||
@ -46,6 +48,7 @@ const BACKENDS: Record<string, ConfigBackend> = {
|
|||||||
extlinux: new Extlinux(),
|
extlinux: new Extlinux(),
|
||||||
configtxt: new ConfigTxt(),
|
configtxt: new ConfigTxt(),
|
||||||
configfs: new ConfigFs(),
|
configfs: new ConfigFs(),
|
||||||
|
splashImage: new SplashImage(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONFIGS = {
|
const CONFIGS = {
|
||||||
@ -88,6 +91,14 @@ const CONFIGS = {
|
|||||||
foobar: 'baz',
|
foobar: 'baz',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
splashImage: {
|
||||||
|
envVars: {
|
||||||
|
HOST_SPLASH_image: 'data:image/png;base64,aaa',
|
||||||
|
},
|
||||||
|
bootConfig: {
|
||||||
|
image: 'data:image/png;base64,aaa',
|
||||||
|
},
|
||||||
|
},
|
||||||
// TO-DO: Config-FS is commented out because it behaves differently and doesn't
|
// TO-DO: Config-FS is commented out because it behaves differently and doesn't
|
||||||
// add value to the Config Utilities if we make it work but would like to add it
|
// add value to the Config Utilities if we make it work but would like to add it
|
||||||
// configfs: {
|
// configfs: {
|
||||||
|
306
test/43-splash-image.spec.ts
Normal file
306
test/43-splash-image.spec.ts
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import { fs, child_process } from 'mz';
|
||||||
|
import { SinonStub, stub } from 'sinon';
|
||||||
|
|
||||||
|
import { expect } from './lib/chai-config';
|
||||||
|
import * as fsUtils from '../src/lib/fs-utils';
|
||||||
|
import { SplashImage } from '../src/config/backends/splash-image';
|
||||||
|
import log from '../src/lib/supervisor-console';
|
||||||
|
|
||||||
|
describe('Splash image configuration', () => {
|
||||||
|
const backend = new SplashImage();
|
||||||
|
const defaultLogo =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/wQDLA+84AAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==';
|
||||||
|
const logo =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=';
|
||||||
|
const uri = `data:image/png;base64,${logo}`;
|
||||||
|
let readDirStub: SinonStub;
|
||||||
|
let readFileStub: SinonStub;
|
||||||
|
let writeFileAtomicStub: SinonStub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup stubs
|
||||||
|
writeFileAtomicStub = stub(fsUtils, 'writeFileAtomic').resolves();
|
||||||
|
stub(child_process, 'exec').resolves();
|
||||||
|
readFileStub = stub(fs, 'readFile').resolves(
|
||||||
|
Buffer.from(logo, 'base64') as any,
|
||||||
|
);
|
||||||
|
readFileStub
|
||||||
|
.withArgs('test/data/mnt/boot/splash/balena-logo-default.png')
|
||||||
|
.resolves(Buffer.from(defaultLogo, 'base64') as any);
|
||||||
|
readDirStub = stub(fs, 'readdir').resolves(['balena-logo.png']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore stubs
|
||||||
|
writeFileAtomicStub.restore();
|
||||||
|
(child_process.exec as SinonStub).restore();
|
||||||
|
readFileStub.restore();
|
||||||
|
readDirStub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialise', () => {
|
||||||
|
it('should make a copy of the existing boot image on initialise if not yet created', async () => {
|
||||||
|
stub(fs, 'exists').resolves(false);
|
||||||
|
|
||||||
|
// Do the initialization
|
||||||
|
await backend.initialise();
|
||||||
|
|
||||||
|
expect(fs.readFile).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should make a copy
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
Buffer.from(logo, 'base64'),
|
||||||
|
);
|
||||||
|
|
||||||
|
(fs.exists as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip initialization if the default image already exists', async () => {
|
||||||
|
stub(fs, 'exists').resolves(true);
|
||||||
|
|
||||||
|
// Do the initialization
|
||||||
|
await backend.initialise();
|
||||||
|
|
||||||
|
expect(fs.exists).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(fs.readFile).to.not.have.been.called;
|
||||||
|
|
||||||
|
(fs.exists as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail initialization if there is no default image on the device', async () => {
|
||||||
|
stub(fs, 'exists').resolves(false);
|
||||||
|
readDirStub.resolves([]);
|
||||||
|
readFileStub.rejects();
|
||||||
|
stub(log, 'warn');
|
||||||
|
|
||||||
|
// Do the initialization
|
||||||
|
await backend.initialise();
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(fs.readFile).to.have.been.calledOnce;
|
||||||
|
expect(log.warn).to.be.calledOnce;
|
||||||
|
|
||||||
|
(log.warn as SinonStub).restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBootConfig', () => {
|
||||||
|
it('should return an empty object if the current logo matches the default logo', async () => {
|
||||||
|
readDirStub.resolves(['resin-logo.png']);
|
||||||
|
|
||||||
|
// The default logo resolves to the same value as the current logo
|
||||||
|
readFileStub
|
||||||
|
.withArgs('test/data/mnt/boot/splash/balena-logo-default.png')
|
||||||
|
.resolves(logo);
|
||||||
|
|
||||||
|
expect(await backend.getBootConfig()).to.deep.equal({});
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledTwice;
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/resin-logo.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read the splash image from resin-logo.png if available', async () => {
|
||||||
|
readDirStub.resolves(['resin-logo.png']);
|
||||||
|
|
||||||
|
expect(await backend.getBootConfig()).to.deep.equal({
|
||||||
|
image: uri,
|
||||||
|
});
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledTwice;
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/resin-logo.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read the splash image from balena-logo.png if available', async () => {
|
||||||
|
readDirStub.resolves(['balena-logo.png']);
|
||||||
|
|
||||||
|
expect(await backend.getBootConfig()).to.deep.equal({
|
||||||
|
image: uri,
|
||||||
|
});
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledTwice;
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read the splash image from balena-logo.png even if resin-logo.png exists', async () => {
|
||||||
|
readDirStub.resolves(['balena-logo.png', 'resin-logo.png']);
|
||||||
|
|
||||||
|
expect(await backend.getBootConfig()).to.deep.equal({
|
||||||
|
image: uri,
|
||||||
|
});
|
||||||
|
expect(readFileStub).to.be.calledTwice;
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(readFileStub).to.be.calledWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should catch readDir errors', async () => {
|
||||||
|
stub(log, 'warn');
|
||||||
|
readDirStub.rejects();
|
||||||
|
|
||||||
|
expect(await backend.getBootConfig()).to.deep.equal({});
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(log.warn).to.be.calledOnce;
|
||||||
|
|
||||||
|
(log.warn as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should catch readFile errors', async () => {
|
||||||
|
stub(log, 'warn');
|
||||||
|
readDirStub.resolves([]);
|
||||||
|
readFileStub.rejects();
|
||||||
|
|
||||||
|
expect(await backend.getBootConfig()).to.deep.equal({});
|
||||||
|
expect(log.warn).to.be.calledOnce;
|
||||||
|
|
||||||
|
(log.warn as SinonStub).restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setBootConfig', () => {
|
||||||
|
it('should write the given image to resin-logo.png if set', async () => {
|
||||||
|
readDirStub.resolves(['resin-logo.png']);
|
||||||
|
|
||||||
|
await backend.setBootConfig({ image: uri });
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/resin-logo.png',
|
||||||
|
Buffer.from(logo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write the given image to balena-logo.png if set', async () => {
|
||||||
|
readDirStub.resolves(['balena-logo.png']);
|
||||||
|
|
||||||
|
await backend.setBootConfig({ image: uri });
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
Buffer.from(logo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write the given image to balena-logo.png by default', async () => {
|
||||||
|
readDirStub.resolves([]);
|
||||||
|
|
||||||
|
await backend.setBootConfig({ image: uri });
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
Buffer.from(logo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept just a base64 as an image', async () => {
|
||||||
|
readDirStub.resolves(['balena-logo.png']);
|
||||||
|
|
||||||
|
await backend.setBootConfig({ image: logo });
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
Buffer.from(logo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore balena-logo.png if image arg is unset', async () => {
|
||||||
|
readDirStub.resolves(['balena-logo.png']);
|
||||||
|
await backend.setBootConfig({});
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
Buffer.from(defaultLogo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore resin-logo.png if image arg is unset', async () => {
|
||||||
|
readDirStub.resolves(['resin-logo.png']);
|
||||||
|
await backend.setBootConfig({});
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/resin-logo.png',
|
||||||
|
Buffer.from(defaultLogo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore balena-logo.png if image arg is empty', async () => {
|
||||||
|
readDirStub.resolves(['balena-logo.png']);
|
||||||
|
await backend.setBootConfig({ image: '' });
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo.png',
|
||||||
|
Buffer.from(defaultLogo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore resin-logo.png if image arg is empty', async () => {
|
||||||
|
readDirStub.resolves(['resin-logo.png']);
|
||||||
|
await backend.setBootConfig({ image: '' });
|
||||||
|
|
||||||
|
expect(readDirStub).to.be.calledOnce;
|
||||||
|
expect(readFileStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/balena-logo-default.png',
|
||||||
|
);
|
||||||
|
expect(writeFileAtomicStub).to.be.calledOnceWith(
|
||||||
|
'test/data/mnt/boot/splash/resin-logo.png',
|
||||||
|
Buffer.from(defaultLogo, 'base64'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if arg is not a valid base64 string', async () => {
|
||||||
|
expect(backend.setBootConfig({ image: 'somestring' })).to.be.rejected;
|
||||||
|
expect(writeFileAtomicStub).to.not.be.called;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if image is not a valid PNG file', async () => {
|
||||||
|
expect(backend.setBootConfig({ image: 'aGVsbG8=' })).to.be.rejected;
|
||||||
|
expect(writeFileAtomicStub).to.not.be.called;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBootConfigVar', () => {
|
||||||
|
it('Accepts any case variable names', () => {
|
||||||
|
expect(backend.isBootConfigVar('HOST_SPLASH_IMAGE')).to.be.true;
|
||||||
|
expect(backend.isBootConfigVar('HOST_SPLASH_image')).to.be.true;
|
||||||
|
expect(backend.isBootConfigVar('HOST_SPLASH_Image')).to.be.true;
|
||||||
|
expect(backend.isBootConfigVar('HOST_SPLASH_ImAgE')).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user