Merge pull request #1132 from balena-io/extract-preload

Extract loadTargetFromFile into it's own module
This commit is contained in:
CameronDiver 2019-11-07 11:30:58 +00:00 committed by GitHub
commit a6e372da60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 232 additions and 126 deletions

26
package-lock.json generated
View File

@ -1972,6 +1972,17 @@
"dns-txt": "^2.0.2", "dns-txt": "^2.0.2",
"multicast-dns": "git+https://github.com/resin-io-modules/multicast-dns.git#listen-on-all-interfaces", "multicast-dns": "git+https://github.com/resin-io-modules/multicast-dns.git#listen-on-all-interfaces",
"multicast-dns-service-types": "^1.1.0" "multicast-dns-service-types": "^1.1.0"
},
"dependencies": {
"multicast-dns": {
"version": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"from": "git+https://github.com/resin-io-modules/multicast-dns.git#listen-on-all-interfaces",
"dev": true,
"requires": {
"dns-packet": "^1.0.1",
"thunky": "^0.1.0"
}
}
} }
}, },
"brace-expansion": { "brace-expansion": {
@ -8235,15 +8246,6 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true "dev": true
}, },
"multicast-dns": {
"version": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"from": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"dev": true,
"requires": {
"dns-packet": "^1.0.1",
"thunky": "^0.1.0"
}
},
"multicast-dns-service-types": { "multicast-dns-service-types": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz",
@ -11501,9 +11503,9 @@
} }
}, },
"typescript": { "typescript": {
"version": "3.5.1", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.1.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.2.tgz",
"integrity": "sha512-64HkdiRv1yYZsSe4xC1WVgamNigVYjlssIoaH2HcZF0+ijsk5YK2g0G34w9wJkze8+5ow4STd22AynfO6ZYYLw==", "integrity": "sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ==",
"dev": true "dev": true
}, },
"uglify-js": { "uglify-js": {

View File

@ -118,7 +118,7 @@
"ts-loader": "^5.3.0", "ts-loader": "^5.3.0",
"ts-node": "^8.3.0", "ts-node": "^8.3.0",
"typed-error": "^2.0.0", "typed-error": "^2.0.0",
"typescript": "^3.5.1", "typescript": "^3.7.0",
"webpack": "^4.25.0", "webpack": "^4.25.0",
"webpack-cli": "^3.1.2", "webpack-cli": "^3.1.2",
"winston": "^3.2.1" "winston": "^3.2.1"

View File

@ -6,7 +6,7 @@ import { EventTracker } from './event-tracker';
import { Logger } from './logger'; import { Logger } from './logger';
import { DeviceApplicationState } from './types/state'; import { DeviceApplicationState } from './types/state';
import Images from './compose/images'; import ImageManager, { Image } from './compose/images';
import ServiceManager from './compose/service-manager'; import ServiceManager from './compose/service-manager';
import DB from './db'; import DB from './db';
@ -53,7 +53,7 @@ export class ApplicationManager extends EventEmitter {
public networks: NetworkManager; public networks: NetworkManager;
public config: Config; public config: Config;
public db: DB; public db: DB;
public images: Images; public images: ImageManager;
public proxyvisor: any; public proxyvisor: any;
@ -81,6 +81,7 @@ export class ApplicationManager extends EventEmitter {
public stopAll(opts: { force?: boolean; skipLock?: boolean }): Promise<void>; public stopAll(opts: { force?: boolean; skipLock?: boolean }): Promise<void>;
public serviceNameFromId(serviceId: number): Bluebird<string>; public serviceNameFromId(serviceId: number): Bluebird<string>;
public imageForService(svc: any): Promise<Image>;
} }
export default ApplicationManager; export default ApplicationManager;

View File

@ -476,7 +476,7 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
); );
} }
private normalise(imageName: string): Bluebird<string> { public normalise(imageName: string): Bluebird<string> {
return this.docker.normaliseImageName(imageName); return this.docker.normaliseImageName(imageName);
} }

View File

@ -16,10 +16,9 @@ constants = require './lib/constants'
validation = require './lib/validation' validation = require './lib/validation'
systemd = require './lib/systemd' systemd = require './lib/systemd'
updateLock = require './lib/update-lock' updateLock = require './lib/update-lock'
{ loadTargetFromFile } = require './device-state/preload'
{ singleToMulticontainerApp } = require './lib/migration' { singleToMulticontainerApp } = require './lib/migration'
{ {
ENOENT,
EISDIR,
NotFoundError, NotFoundError,
UpdatesLockedError UpdatesLockedError
} = require './lib/errors' } = require './lib/errors'
@ -301,7 +300,7 @@ module.exports = class DeviceState extends EventEmitter
@applications.getTargetApps() @applications.getTargetApps()
.then (targetApps) => .then (targetApps) =>
if !conf.provisioned or (_.isEmpty(targetApps) and !conf.targetStateSet) if !conf.provisioned or (_.isEmpty(targetApps) and !conf.targetStateSet)
@loadTargetFromFile() loadTargetFromFile(null, this)
.finally => .finally =>
@config.set({ targetStateSet: 'true' }) @config.set({ targetStateSet: 'true' })
else else
@ -423,14 +422,6 @@ module.exports = class DeviceState extends EventEmitter
_.assign(@_currentVolatile, newState) _.assign(@_currentVolatile, newState)
@emitAsync('change') @emitAsync('change')
_convertLegacyAppsJson: (appsArray) ->
Promise.try ->
deviceConf = _.reduce(appsArray, (conf, app) ->
return _.merge({}, conf, app.config)
, {})
apps = _.keyBy(_.map(appsArray, singleToMulticontainerApp), 'appId')
return { apps, config: deviceConf }
restoreBackup: (targetState) => restoreBackup: (targetState) =>
@setTarget(targetState) @setTarget(targetState)
.then => .then =>
@ -469,73 +460,6 @@ module.exports = class DeviceState extends EventEmitter
.then -> .then ->
rimraf(path.join(constants.rootMountPoint, 'mnt/data', constants.migrationBackupFile)) rimraf(path.join(constants.rootMountPoint, 'mnt/data', constants.migrationBackupFile))
loadTargetFromFile: (appsPath) ->
log.info('Attempting to load preloaded apps...')
appsPath ?= constants.appsJsonPath
fs.readFileAsync(appsPath, 'utf8')
.then(JSON.parse)
.then (stateFromFile) =>
if _.isArray(stateFromFile)
# This is a legacy apps.json
log.debug('Legacy apps.json detected')
return @_convertLegacyAppsJson(stateFromFile)
else
return stateFromFile
.then (stateFromFile) =>
commitToPin = null
appToPin = null
if !_.isEmpty(stateFromFile)
images = _.flatMap stateFromFile.apps, (app, appId) =>
# multi-app warning!
# The following will need to be changed once running multiple applications is possible
commitToPin = app.commit
appToPin = appId
_.map app.services, (service, serviceId) =>
svc = {
imageName: service.image
serviceName: service.serviceName
imageId: service.imageId
serviceId
releaseId: app.releaseId
appId
}
return @applications.imageForService(svc)
Promise.map images, (img) =>
@applications.images.normalise(img.name)
.then (name) =>
img.name = name
@applications.images.save(img)
.then =>
@deviceConfig.getCurrent()
.then (deviceConf) =>
@deviceConfig.formatConfigKeys(stateFromFile.config)
.then (formattedConf) =>
stateFromFile.config = _.defaults(formattedConf, deviceConf)
stateFromFile.name ?= ''
@setTarget({
local: stateFromFile
})
.then =>
log.success('Preloading complete')
if stateFromFile.pinDevice
# multi-app warning!
# The following will need to be changed once running multiple applications is possible
log.debug('Device will be pinned')
if commitToPin? and appToPin?
@config.set
pinDevice: {
commit: commitToPin,
app: parseInt(appToPin, 10),
}
# Ensure that this is actually a file, and not an empty path
# It can be an empty path because if the file does not exist
# on host, the docker daemon creates an empty directory when
# the bind mount is added
.catch ENOENT, EISDIR, ->
log.debug('No apps.json file present, skipping preload')
.catch (err) =>
@eventTracker.track('Loading preloaded apps failed', { error: err })
reboot: (force, skipLock) => reboot: (force, skipLock) =>
@applications.stopAll({ force, skipLock }) @applications.stopAll({ force, skipLock })
.then => .then =>

View File

@ -14,6 +14,8 @@ class DeviceState extends EventEmitter {
public applications: ApplicationManager; public applications: ApplicationManager;
public router: Router; public router: Router;
public deviceConfig: DeviceConfig; public deviceConfig: DeviceConfig;
public config: Config;
public eventTracker: EventTracker;
public constructor(args: { public constructor(args: {
config: Config; config: Config;

115
src/device-state/preload.ts Normal file
View File

@ -0,0 +1,115 @@
import * as _ from 'lodash';
import { fs } from 'mz';
import { Image } from '../compose/images';
import DeviceState = require('../device-state');
import constants = require('../lib/constants');
import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors';
import log from '../lib/supervisor-console';
import { convertLegacyAppsJson } from '../lib/migration';
import { AppsJsonFormat } from '../types/state';
export async function loadTargetFromFile(
appsPath: Nullable<string>,
deviceState: DeviceState,
): Promise<void> {
log.info('Attempting to load any preloaded applications');
if (!appsPath) {
appsPath = constants.appsJsonPath;
}
try {
const content = await fs.readFile(appsPath, 'utf8');
// It's either a target state or it's a list of legacy
// style application definitions, we reconcile this below
let stateFromFile: AppsJsonFormat | any[];
try {
stateFromFile = JSON.parse(content);
} catch (e) {
throw new AppsJsonParseError(e);
}
if (_.isArray(stateFromFile)) {
log.debug('Detected a legacy apps.json, converting...');
stateFromFile = convertLegacyAppsJson(stateFromFile);
}
const preloadState = stateFromFile as AppsJsonFormat;
let commitToPin: string | undefined;
let appToPin: string | undefined;
if (_.isEmpty(preloadState)) {
return;
}
const images: Image[] = [];
const appIds = _.keys(preloadState.apps);
for (const appId of appIds) {
const app = preloadState.apps[appId];
// Multi-app warning!
// The following will need to be changed once running
// multiple applications is possible
commitToPin = app.commit;
appToPin = appId;
const serviceIds = _.keys(app.services);
for (const serviceId of serviceIds) {
const service = app.services[serviceId];
const svc = {
imageName: service.image,
serviceName: service.serviceName,
imageId: service.imageId,
serviceId,
releaseId: app.releaseId,
appId,
};
images.push(await deviceState.applications.imageForService(svc));
}
}
for (const image of images) {
const name = await deviceState.applications.images.normalise(image.name);
image.name = name;
await deviceState.applications.images.save(image);
}
const deviceConf = await deviceState.deviceConfig.getCurrent();
const formattedConf = await deviceState.deviceConfig.formatConfigKeys(
preloadState.config,
);
preloadState.config = { ...formattedConf, ...deviceConf };
const localState = { local: { name: '', ...preloadState } };
await deviceState.setTarget(localState);
log.success('Preloading complete');
if (stateFromFile.pinDevice) {
// Multi-app warning!
// The following will need to be changed once running
// multiple applications is possible
if (commitToPin != null && appToPin != null) {
log.debug('Device will be pinned');
await deviceState.config.set({
pinDevice: {
commit: commitToPin,
app: parseInt(appToPin, 10),
},
});
}
}
} catch (e) {
// Ensure that this is actually a file, and not an empty path
// It can be an empty path because if the file does not exist
// on host, the docker daemon creates an empty directory when
// the bind mount is added
if (ENOENT(e) || EISDIR(e)) {
log.debug('No apps.json file present, skipping preload');
} else {
deviceState.eventTracker.track('Loading preloaded apps failed', {
error: e,
});
}
}
}

View File

@ -101,3 +101,5 @@ export class ContractViolationError extends TypedError {
); );
} }
} }
export class AppsJsonParseError extends TypedError {}

View File

@ -1,10 +1,12 @@
import * as _ from 'lodash'; import * as _ from 'lodash';
import { Application } from '../types/application'; import { AppsJsonFormat, TargetApplication } from '../types/state';
export const defaultLegacyVolume = () => 'resin-data'; export const defaultLegacyVolume = () => 'resin-data';
export function singleToMulticontainerApp(app: Dictionary<any>): Application { export function singleToMulticontainerApp(
app: Dictionary<any>,
): TargetApplication & { appId: string } {
const environment: Dictionary<string> = {}; const environment: Dictionary<string> = {};
for (const key in app.env) { for (const key in app.env) {
if (!/^RESIN_/.test(key)) { if (!/^RESIN_/.test(key)) {
@ -14,15 +16,15 @@ export function singleToMulticontainerApp(app: Dictionary<any>): Application {
const { appId } = app; const { appId } = app;
const conf = app.config != null ? app.config : {}; const conf = app.config != null ? app.config : {};
const newApp = new Application(); const newApp: TargetApplication & { appId: string } = {
_.assign(newApp, { appId: appId.toString(),
appId,
commit: app.commit, commit: app.commit,
name: app.name, name: app.name,
releaseId: 1, releaseId: 1,
networks: {}, networks: {},
volumes: {}, volumes: {},
}); services: {},
};
const defaultVolume = exports.defaultLegacyVolume(); const defaultVolume = exports.defaultLegacyVolume();
newApp.volumes[defaultVolume] = {}; newApp.volumes[defaultVolume] = {};
const updateStrategy = const updateStrategy =
@ -67,3 +69,16 @@ export function singleToMulticontainerApp(app: Dictionary<any>): Application {
}; };
return newApp; return newApp;
} }
export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
const deviceConfig = _.reduce(
appsArray,
(conf, app) => {
return _.merge({}, conf, app.config);
},
{},
);
const apps = _.keyBy(_.map(appsArray, singleToMulticontainerApp), 'appId');
return { apps, config: deviceConfig } as AppsJsonFormat;
}

View File

@ -1,12 +0,0 @@
import { ServiceComposeConfig } from '../compose/types/service';
export class Application {
public appId: number;
public commit: string;
public name: string;
public releaseId: number;
public networks: Dictionary<any>;
public volumes: Dictionary<any>;
public services: Dictionary<ServiceComposeConfig>;
}

View File

@ -1,3 +1,7 @@
import { ComposeNetworkConfig } from '../compose/types/network';
import { ServiceComposeConfig } from '../compose/types/service';
import { ComposeVolumeConfig } from '../compose/volume';
export interface DeviceApplicationState { export interface DeviceApplicationState {
local?: { local?: {
config?: Dictionary<string>; config?: Dictionary<string>;
@ -17,3 +21,52 @@ export interface DeviceApplicationState {
dependent?: any; dependent?: any;
commit?: string; commit?: string;
} }
// TODO: Define this with io-ts so we can perform validation
// on the target state from the api, local mode, and preload
export interface TargetState {
local: {
name: string;
config: Dictionary<string>;
apps: {
[appId: string]: {
name: string;
commit: string;
releaseId: number;
services: {
[serviceId: string]: {
labels: Dictionary<string>;
imageId: number;
serviceName: string;
image: string;
running: boolean;
environment: Dictionary<string>;
} & ServiceComposeConfig;
};
volumes: Dictionary<Partial<ComposeVolumeConfig>>;
networks: Dictionary<Partial<ComposeNetworkConfig>>;
};
};
};
// TODO: Correctly type this once dependent devices are
// actually properly supported
dependent: Dictionary<any>;
}
export type LocalTargetState = TargetState['local'];
export type TargetApplications = LocalTargetState['apps'];
export type TargetApplication = LocalTargetState['apps'][0];
export type AppsJsonFormat = Omit<TargetState['local'], 'name'> & {
pinDevice?: boolean;
};
export type ApplicationDatabaseFormat = Array<{
appId: number;
commit: string;
name: string;
source: string;
releaseId: number;
services: string;
networks: string;
volumes: string;
}>;

View File

@ -14,6 +14,8 @@ import { RPiConfigBackend } from '../src/config/backend';
import DB from '../src/db'; import DB from '../src/db';
import DeviceState = require('../src/device-state'); import DeviceState = require('../src/device-state');
import { loadTargetFromFile } from '../src/device-state/preload';
import Service from '../src/compose/service'; import Service from '../src/compose/service';
const mockedInitialConfig = { const mockedInitialConfig = {
@ -260,8 +262,9 @@ describe('deviceState', () => {
); );
try { try {
await deviceState.loadTargetFromFile( await loadTargetFromFile(
process.env.ROOT_MOUNTPOINT + '/apps.json', process.env.ROOT_MOUNTPOINT + '/apps.json',
deviceState,
); );
const targetState = await deviceState.getTarget(); const targetState = await deviceState.getTarget();
@ -288,21 +291,22 @@ describe('deviceState', () => {
stub(deviceState.deviceConfig, 'getCurrent').returns( stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig), Promise.resolve(mockedInitialConfig),
); );
deviceState loadTargetFromFile(
.loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps-pin.json') process.env.ROOT_MOUNTPOINT + '/apps-pin.json',
.then(() => { deviceState,
(deviceState as any).applications.images.save.restore(); ).then(() => {
(deviceState as any).deviceConfig.getCurrent.restore(); (deviceState as any).applications.images.save.restore();
(deviceState as any).deviceConfig.getCurrent.restore();
config.get('pinDevice').then(pinned => { config.get('pinDevice').then(pinned => {
expect(pinned) expect(pinned)
.to.have.property('app') .to.have.property('app')
.that.equals(1234); .that.equals(1234);
expect(pinned) expect(pinned)
.to.have.property('commit') .to.have.property('commit')
.that.equals('abcdef'); .that.equals('abcdef');
});
}); });
});
}); });
it('emits a change event when a new state is reported', () => { it('emits a change event when a new state is reported', () => {