Merge pull request #1090 from balena-io/container-contracts

Add support for container contracts
This commit is contained in:
CameronDiver 2019-10-01 17:43:35 +01:00 committed by GitHub
commit 7c9e2f4287
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1921 additions and 170 deletions

1128
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@
"node": "^6.13.1"
},
"devDependencies": {
"@balena/contrato": "^0.2.1",
"@types/bluebird": "^3.5.25",
"@types/chai": "^4.1.7",
"@types/common-tags": "^1.8.0",
@ -78,9 +79,10 @@
"event-stream": "3.3.4",
"express": "^4.0.0",
"fork-ts-checker-webpack-plugin": "^0.5.2",
"fp-ts": "^1.12.2",
"fp-ts": "^2.0.0",
"husky": "^1.3.0",
"io-ts": "^1.5.1",
"io-ts": "^2.0.1",
"io-ts-reporters": "^1.0.0",
"istanbul": "^0.4.5",
"json-mask": "^0.3.8",
"knex": "~0.15.2",

View File

@ -16,18 +16,21 @@ import { EventTracker } from './event-tracker';
import * as constants from './lib/constants';
import {
ContractValidationError,
ContractViolationError,
DuplicateUuidError,
ExchangeKeyError,
InternalInconsistencyError,
} from './lib/errors';
import { pathExistsOnHost } from './lib/fs-utils';
import { request, requestOpts } from './lib/request';
import * as request from './lib/request';
import { writeLock } from './lib/update-lock';
import { DeviceApplicationState } from './types/state';
import log from './lib/supervisor-console';
import DeviceState = require('./device-state');
import Logger from './logger';
const REPORT_SUCCESS_DELAY = 1000;
const MAX_REPORT_RETRY_DELAY = 60000;
@ -44,6 +47,7 @@ export interface APIBinderConstructOpts {
db: Database;
deviceState: DeviceState;
eventTracker: EventTracker;
logger: Logger;
}
interface Device {
@ -74,6 +78,7 @@ export class APIBinder {
[key: string]: any;
};
private eventTracker: EventTracker;
private logger: Logger;
public balenaApi: PinejsClientRequest | null = null;
private cachedBalenaApi: PinejsClientRequest | null = null;
@ -96,10 +101,12 @@ export class APIBinder {
config,
deviceState,
eventTracker,
logger,
}: APIBinderConstructOpts) {
this.config = config;
this.deviceState = deviceState;
this.eventTracker = eventTracker;
this.logger = logger;
this.router = this.createAPIBinderRouter(this);
}
@ -146,7 +153,7 @@ export class APIBinder {
}
const baseUrl = url.resolve(apiEndpoint, '/v5/');
const passthrough = _.cloneDeep(requestOpts);
const passthrough = _.cloneDeep(await request.getRequestOptions());
passthrough.headers =
passthrough.headers != null ? passthrough.headers : {};
passthrough.headers.Authorization = `Bearer ${currentApiKey}`;
@ -408,9 +415,9 @@ export class APIBinder {
);
}
return {
id: id.value,
name: name.value,
value: value.value,
id: id.right,
name: name.right,
value: value.right,
};
});
}
@ -580,9 +587,25 @@ export class APIBinder {
this.deviceState.triggerApplyTarget({ force, isFromApi });
}
})
.tapCatch(err => {
log.error(`Failed to get target state for device: ${err}`);
.tapCatch(ContractValidationError, ContractViolationError, e => {
log.error(`Could not store target state for device: ${e}`);
this.logger.logSystemMessage(
`Could not move to new release: ${e.message}`,
{},
'targetStateRejection',
false,
);
})
.tapCatch(
(e: unknown) =>
!(
e instanceof ContractValidationError ||
e instanceof ContractViolationError
),
err => {
log.error(`Failed to get target state for device: ${err}`);
},
)
.finally(() => {
this.lastTargetStateFetch = process.hrtime();
});
@ -781,7 +804,7 @@ export class APIBinder {
}
// We found the device so we can try to register a working device key for it
const [res] = await request
const [res] = await (await request.getRequestInstance())
.postAsync(`${opts.apiEndpoint}/api-key/device/${device.id}/device-key`, {
json: true,
body: {

View File

@ -9,13 +9,16 @@ path = require 'path'
constants = require './lib/constants'
{ log } = require './lib/supervisor-console'
{ containerContractsFulfilled, validateContract } = require './lib/contracts'
{ DockerUtils: Docker } = require './lib/docker-utils'
{ LocalModeManager } = require './local-mode'
updateLock = require './lib/update-lock'
{ checkTruthy, checkInt, checkString } = require './lib/validation'
{ NotFoundError } = require './lib/errors'
{ ContractViolationError, ContractValidationError, NotFoundError } = require './lib/errors'
{ pathExistsOnHost } = require './lib/fs-utils'
{ ApplicationTargetStateWrapper } = require './target-state'
{ ServiceManager } = require './compose/service-manager'
{ Service } = require './compose/service'
{ Images } = require './compose/images'
@ -79,6 +82,8 @@ module.exports = class ApplicationManager extends EventEmitter
@_targetVolatilePerImageId = {}
@_containerStarted = {}
@targetStateWrapper = new ApplicationTargetStateWrapper(this, @config, @db)
@config.on 'change', (changedConfig) =>
if changedConfig.appUpdatePollInterval
@images.appUpdatePollInterval = changedConfig.appUpdatePollInterval
@ -251,9 +256,7 @@ module.exports = class ApplicationManager extends EventEmitter
).get(appId)
getTargetApp: (appId) =>
@config.get('apiEndpoint').then (endpoint) ->
@db.models('app').where({ appId, source: endpoint }).select()
.then ([ app ]) =>
@targetStateWrapper.getTargetApp(appId).then (app) =>
if !app?
return
@normaliseAndExtendAppFromDB(app)
@ -681,29 +684,67 @@ module.exports = class ApplicationManager extends EventEmitter
)
setTarget: (apps, dependent , source, trx) =>
setInTransaction = (trx) =>
# We look at the container contracts here, as if we
# cannot run the release, we don't want it to be added
# to the database, overwriting the current release. This
# is because if we just reject the release, but leave it
# in the db, if for any reason the current state stops
# running, we won't restart it, leaving the device useless
contractsFulfilled = _.mapValues apps, (app) ->
serviceContracts = {}
_.each app.services, (s) ->
if s.contract?
try
validateContract(s)
catch e
throw new ContractValidationError(s.serviceName, e.message)
serviceContracts[s.serviceName] = s.contract
if !_.isEmpty(serviceContracts)
containerContractsFulfilled(serviceContracts)
else
{ valid: true }
setInTransaction = (filteredApps, trx) =>
Promise.try =>
appsArray = _.map apps, (app, appId) ->
appsArray = _.map filteredApps, (app, appId) ->
appClone = _.clone(app)
appClone.appId = checkInt(appId)
appClone.source = source
return appClone
Promise.map(appsArray, @normaliseAppForDB)
.tap (appsForDB) =>
Promise.map appsForDB, (app) =>
@db.upsertModel('app', app, { appId: app.appId }, trx)
@targetStateWrapper.setTargetApps(appsForDB, trx)
.then (appsForDB) ->
trx('app').where({ source }).whereNotIn('appId', _.map(appsForDB, 'appId')).del()
trx('app').where({ source }).whereNotIn('appId',
# Use apps here, rather than filteredApps, to
# avoid removing a release from the database
# without an application to replace it.
# Currently this will only happen if the release
# which would replace it fails a contract
# validation check
_.map(apps, (_, appId) -> checkInt(appId))
).del()
.then =>
@proxyvisor.setTargetInTransaction(dependent, trx)
Promise.try =>
contractViolators = {}
Promise.props(contractsFulfilled).then (fulfilledContracts) ->
filteredApps = _.cloneDeep(apps)
_.each fulfilledContracts, ({ valid, unmetServices }, appId) ->
if not valid
contractViolators[apps[appId].name] = unmetServices
delete filteredApps[appId]
if trx?
setInTransaction(trx)
setInTransaction(filteredApps, trx)
else
@db.transaction(setInTransaction)
.then =>
@_targetVolatilePerImageId = {}
.finally ->
if not _.isEmpty(contractViolators)
throw new ContractViolationError(contractViolators)
setTargetVolatileForService: (imageId, target) =>
@_targetVolatilePerImageId[imageId] ?= {}
@ -714,11 +755,7 @@ module.exports = class ApplicationManager extends EventEmitter
@_targetVolatilePerImageId[imageId] = {}
getTargetApps: =>
@config.getMany(['apiEndpoint', 'localMode']). then ({ apiEndpoint, localMode }) =>
source = apiEndpoint
if localMode
source = 'local'
Promise.map(@db.models('app').where({ source }), @normaliseAndExtendAppFromDB)
Promise.map(@targetStateWrapper.getTargetApps(), @normaliseAndExtendAppFromDB)
.map (app) =>
if !_.isEmpty(app.services)
app.services = _.map app.services, (service) =>
@ -936,6 +973,7 @@ module.exports = class ApplicationManager extends EventEmitter
conf = { delta, localMode }
if conf.localMode
cleanupNeeded = false
@_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf, containerIds)
.then (nextSteps) =>
if ignoreImages and _.some(nextSteps, action: 'fetch')

View File

@ -1,6 +1,7 @@
import * as Bluebird from 'bluebird';
import * as Dockerode from 'dockerode';
import { EventEmitter } from 'events';
import { isLeft } from 'fp-ts/lib/Either';
import * as JSONStream from 'JSONStream';
import * as _ from 'lodash';
import { fs } from 'mz';
@ -326,14 +327,14 @@ export class ServiceManager extends (EventEmitter as new () => ServiceManagerEve
// Get the statusCode from the original cause and make sure it's
// definitely an int for comparison reasons
const maybeStatusCode = PermissiveNumber.decode(e.statusCode);
if (maybeStatusCode.isLeft()) {
if (isLeft(maybeStatusCode)) {
remove = true;
err = new Error(
`Could not parse status code from docker error: ${e}`,
);
throw err;
}
const statusCode = maybeStatusCode.value;
const statusCode = maybeStatusCode.right;
const message = e.message;
// 304 means the container was already started, precisely what we want
@ -568,12 +569,12 @@ export class ServiceManager extends (EventEmitter as new () => ServiceManagerEve
// Get the statusCode from the original cause and make sure it's
// definitely an int for comparison reasons
const maybeStatusCode = PermissiveNumber.decode(e.statusCode);
if (maybeStatusCode.isLeft()) {
if (isLeft(maybeStatusCode)) {
throw new Error(
`Could not parse status code from docker error: ${e}`,
);
}
const statusCode = maybeStatusCode.value;
const statusCode = maybeStatusCode.right;
// 304 means the container was already stopped, so we can just remove it
if (statusCode === 304) {

View File

@ -113,6 +113,8 @@ export class Service {
service.createdAt = appConfig.createdAt;
delete appConfig.createdAt;
delete appConfig.contract;
// We don't need this value
delete appConfig.commit;

View File

@ -6,7 +6,7 @@ import { generateUniqueKey } from 'resin-register-device';
import StrictEventEmitter from 'strict-event-emitter-types';
import { inspect } from 'util';
import { Either } from 'fp-ts/lib/Either';
import { Either, isLeft, isRight, Right } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import ConfigJsonConfigBackend from './configJson';
@ -84,16 +84,19 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
undefined,
);
this.checkValueDecode(maybeDecoded, key, undefined);
return maybeDecoded.value;
return (
this.checkValueDecode(maybeDecoded, key, undefined) &&
maybeDecoded.right
);
}
return defaultValue as SchemaReturn<T>;
}
const decoded = this.decodeSchema(schemaKey, value);
this.checkValueDecode(decoded, key, value);
return decoded.value;
// The following function will throw if the value
// is not correct, so we chain it this way to keep
// the type system happy
return this.checkValueDecode(decoded, key, value) && decoded.right;
});
} else if (FnSchema.fnSchema.hasOwnProperty(key)) {
const fnKey = key as FnSchema.FnSchemaKey;
@ -105,9 +108,7 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
return promiseValue.then((value: unknown) => {
const decoded = schemaTypes[key].type.decode(value);
this.checkValueDecode(decoded, key, value);
return decoded.value as SchemaReturn<T>;
return this.checkValueDecode(decoded, key, value) && decoded.right;
});
} else {
throw new Error(`Unknown config value ${key}`);
@ -147,7 +148,6 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
throw new Error(
`Unknown configuration source: ${source} for config key: ${k}`,
);
break;
}
});
@ -181,7 +181,8 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
};
return Bluebird.try(() => {
// Firstly validate and coerce all of the types as they are being set
// Firstly validate and coerce all of the types as
// they are being set
keyValues = this.validateConfigMap(keyValues);
if (trx != null) {
@ -243,7 +244,7 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
type = schemaTypesEntry.type;
}
return type.decode(value).isRight();
return isRight(type.decode(value));
}
private async getSchema<T extends Schema.SchemaKey>(
@ -301,7 +302,7 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
}
const decoded = type.decode(value);
if (decoded.isLeft()) {
if (isLeft(decoded)) {
throw new TypeError(
`Cannot set value for ${key}, as value failed validation: ${inspect(
value,
@ -309,7 +310,7 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
)}`,
);
}
return decoded.value;
return decoded.right;
}) as ConfigMap<T>;
}
@ -355,10 +356,11 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) {
decoded: Either<t.Errors, unknown>,
key: string,
value: unknown,
): void {
if (decoded.isLeft()) {
): decoded is Right<unknown> {
if (isLeft(decoded)) {
throw new ConfigurationValidationError(key, value);
}
return true;
}
}

View File

@ -1,3 +1,4 @@
import { either, isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import * as _ from 'lodash';
@ -17,7 +18,7 @@ export const PermissiveBoolean = new t.Type<boolean, t.TypeOf<PermissiveType>>(
'PermissiveBoolean',
_.isBoolean,
(m, c) =>
permissiveValue.validate(m, c).chain(v => {
either.chain(permissiveValue.validate(m, c), v => {
switch (typeof v) {
case 'string':
case 'boolean':
@ -50,23 +51,20 @@ export const PermissiveNumber = new t.Type<number, string | number>(
'PermissiveNumber',
_.isNumber,
(m, c) =>
t
.union([t.string, t.number])
.validate(m, c)
.chain(v => {
switch (typeof v) {
case 'number':
return t.success(v);
case 'string':
const i = parseInt(v, 10);
if (_.isNaN(i)) {
return t.failure(v, c);
}
return t.success(i);
default:
either.chain(t.union([t.string, t.number]).validate(m, c), v => {
switch (typeof v) {
case 'number':
return t.success(v);
case 'string':
const i = parseInt(v, 10);
if (_.isNaN(i)) {
return t.failure(v, c);
}
}),
}
return t.success(i);
default:
return t.failure(v, c);
}
}),
() => {
throw new InternalInconsistencyError(
'Encode not defined for PermissiveNumber',
@ -80,22 +78,19 @@ export class StringJSON<T> extends t.Type<T, string> {
constructor(type: t.InterfaceType<any>) {
super(
'StringJSON',
(m): m is T => type.decode(m).isRight(),
(m): m is T => isRight(type.decode(m)),
(m, c) =>
// Accept either an object, or a string which represents the
// object
t
.union([t.string, type])
.validate(m, c)
.chain(v => {
let obj: T;
if (typeof v === 'string') {
obj = JSON.parse(v);
} else {
obj = v;
}
return type.decode(obj);
}),
either.chain(t.union([t.string, type]).validate(m, c), v => {
let obj: T;
if (typeof v === 'string') {
obj = JSON.parse(v);
} else {
obj = v;
}
return type.decode(obj);
}),
() => {
throw new InternalInconsistencyError(
'Encode not defined for StringJSON',

View File

@ -17,7 +17,12 @@ validation = require './lib/validation'
systemd = require './lib/systemd'
updateLock = require './lib/update-lock'
{ singleToMulticontainerApp } = require './lib/migration'
{ ENOENT, EISDIR, NotFoundError, UpdatesLockedError } = require './lib/errors'
{
ENOENT,
EISDIR,
NotFoundError,
UpdatesLockedError
} = require './lib/errors'
{ DeviceConfig } = require './device-config'
ApplicationManager = require './application-manager'

122
src/lib/contracts.ts Normal file
View File

@ -0,0 +1,122 @@
import { isLeft } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import { reporter } from 'io-ts-reporters';
import * as _ from 'lodash';
import { Blueprint, Contract, ContractObject } from '@balena/contrato';
import constants = require('./constants');
import { InternalInconsistencyError } from './errors';
import * as osRelease from './os-release';
import supervisorVersion = require('./supervisor-version');
export { ContractObject };
export interface ServiceContracts {
[serviceName: string]: ContractObject;
}
export async function containerContractsFulfilled(
serviceContracts: ServiceContracts,
): Promise<{ valid: boolean; unmetServices: string[] }> {
const containers = _.values(serviceContracts);
const osContract = new Contract({
slug: 'balenaOS',
type: 'sw.os',
name: 'balenaOS',
version: await osRelease.getOSSemver(constants.hostOSVersionPath),
});
const supervisorContract = new Contract({
slug: 'balena-supervisor',
type: 'sw.supervisor',
name: 'balena-supervisor',
version: supervisorVersion,
});
const blueprint = new Blueprint(
{
'sw.os': 1,
'sw.supervisor': 1,
'sw.container': '1+',
},
{
type: 'sw.runnable.configuration',
slug: '{{children.sw.container.slug}}',
},
);
const universe = new Contract({
type: 'meta.universe',
});
universe.addChildren(
[osContract, supervisorContract].concat(
containers.map(c => new Contract(c)),
),
);
const solution = blueprint.reproduce(universe);
if (solution.length > 1) {
throw new InternalInconsistencyError(
'More than one solution available for container contracts when only one is expected!',
);
}
if (solution.length === 0) {
return { valid: false, unmetServices: _.keys(serviceContracts) };
}
// Detect how many containers are present in the resulting
// solution
const children = solution[0].getChildren({
types: new Set(['sw.container']),
});
if (children.length === containers.length) {
return { valid: true, unmetServices: [] };
} else {
// Work out which service violated the contracts they
// provided
const unmetServices = _(serviceContracts)
.map((contract, serviceName) => {
const found = _.find(children, child => {
return _.isEqual((child as any).raw, contract);
});
if (found == null) {
return serviceName;
}
return;
})
.filter(n => n != null)
.value() as string[];
return { valid: false, unmetServices };
}
}
const contractObjectValidator = t.type({
slug: t.string,
requires: t.union([
t.null,
t.undefined,
t.array(
t.type({
type: t.string,
version: t.union([t.null, t.undefined, t.string]),
}),
),
]),
});
export function validateContract(
contract: unknown,
): contract is ContractObject {
const result = contractObjectValidator.decode(contract);
if (isLeft(result)) {
throw new Error(reporter(result).join('\n'));
}
return true;
}

View File

@ -13,7 +13,7 @@ import {
ImageAuthenticationError,
InvalidNetGatewayError,
} from './errors';
import { request, requestLib, resumable } from './request';
import * as request from './request';
import { EnvVarObject } from './types';
import log from './supervisor-console';
@ -108,7 +108,7 @@ export class DockerUtils extends DockerToolbelt {
const token = await this.getAuthToken(srcInfo, dstInfo, deltaOpts);
const opts: requestLib.CoreOptions = {
const opts: request.requestLib.CoreOptions = {
followRedirect: false,
timeout: deltaOpts.deltaRequestTimeout,
auth: {
@ -121,7 +121,10 @@ export class DockerUtils extends DockerToolbelt {
deltaOpts.deltaVersion
}/delta?src=${deltaOpts.deltaSource}&dest=${imgDest}`;
const [res, data] = await request.getAsync(url, opts);
const [res, data] = await (await request.getRequestInstance()).getAsync(
url,
opts,
);
if (res.statusCode === 502 || res.statusCode === 504) {
throw new DeltaStillProcessingError();
}
@ -265,7 +268,8 @@ export class DockerUtils extends DockerToolbelt {
): Promise<string> {
logFn('Applying rsync delta...');
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
const resumable = await request.getResumableRequest();
const req = resumable(Object.assign({ url: deltaUrl }, opts));
req
.on('progress', onProgress)
@ -328,7 +332,7 @@ export class DockerUtils extends DockerToolbelt {
deltaOpts: DeltaFetchOptions,
): Promise<string> => {
const tokenEndpoint = `${deltaOpts.apiEndpoint}/auth/v1/token`;
const tokenOpts: requestLib.CoreOptions = {
const tokenOpts: request.requestLib.CoreOptions = {
auth: {
user: `d_${deltaOpts.uuid}`,
pass: deltaOpts.currentApiKey,
@ -342,7 +346,7 @@ export class DockerUtils extends DockerToolbelt {
srcInfo.imageName
}:pull`;
const tokenResponseBody = (await request.getAsync(
const tokenResponseBody = (await (await request.getRequestInstance()).getAsync(
tokenUrl,
tokenOpts,
))[1];

View File

@ -1,4 +1,4 @@
import { endsWith, startsWith } from 'lodash';
import { endsWith, map, startsWith } from 'lodash';
import TypedError = require('typed-error');
import { checkInt } from './validation';
@ -68,3 +68,36 @@ export class ImageAuthenticationError extends TypedError {}
* See LocalModeManager for a usage example.
*/
export class SupervisorContainerNotFoundError extends TypedError {}
/**
* This error is thrown when a container contract does not
* match the minimum we expect from it
*/
export class ContractValidationError extends TypedError {
constructor(serviceName: string, error: string) {
super(
`The contract for service ${serviceName} failed validation, with error: ${error}`,
);
}
}
/**
* This error is thrown when one or releases cannot be ran
* as one or more of their container have unmet requirements.
* It accepts a map of app names to arrays of service names
* which have unmet requirements.
*/
export class ContractViolationError extends TypedError {
constructor(violators: { [appName: string]: string[] }) {
const appStrings = map(
violators,
(svcs, name) =>
`${name}: Services with unmet requirements: ${svcs.join(', ')}`,
);
super(
`Some releases were rejected due to having unmet requirements:\n ${appStrings.join(
'\n ',
)}`,
);
}
}

View File

@ -1,49 +1,60 @@
import * as Bluebird from 'bluebird';
import * as fs from 'fs';
import * as _ from 'lodash';
import fs = require('mz/fs');
import { InternalInconsistencyError } from './errors';
import log from './supervisor-console';
// FIXME: Don't use synchronous file reading and change call sites to support a promise
function getOSReleaseField(path: string, field: string): string | undefined {
// Retrieve the data for the OS once only per path
const getOSReleaseData = _.memoize(
async (path: string): Promise<Dictionary<string>> => {
const releaseItems: Dictionary<string> = {};
try {
const releaseData = await fs.readFile(path, 'utf-8');
const lines = releaseData.split('\n');
for (const line of lines) {
const [key, ...values] = line.split('=');
// Remove enclosing quotes: http://stackoverflow.com/a/19156197/2549019
const value = _.trim(values.join('=')).replace(/^"(.+(?="$))"$/, '$1');
releaseItems[_.trim(key)] = value;
}
} catch (e) {
throw new InternalInconsistencyError(
`Unable to read file at ${path}: ${e.message} ${e.stack}`,
);
}
return releaseItems;
},
);
async function getOSReleaseField(
path: string,
field: string,
): Promise<string | undefined> {
try {
const releaseData = fs.readFileSync(path, 'utf-8');
const lines = releaseData.split('\n');
const releaseItems: { [field: string]: string } = {};
for (const line of lines) {
const [key, value] = line.split('=');
releaseItems[_.trim(key)] = _.trim(value);
const data = await getOSReleaseData(path);
const value = data[field];
if (value == null) {
log.warn(
`Field ${field} is not available in OS information file: ${path}`,
);
}
if (releaseItems[field] == null) {
throw new Error(`Field ${field} not available in ${path}`);
}
// Remove enclosing quotes: http://stackoverflow.com/a/19156197/2549019
return releaseItems[field].replace(/^"(.+(?="$))"$/, '$1');
} catch (err) {
log.error('Could not get OS release field: ', err);
return;
return data[field];
} catch (e) {
log.warn('Unable to read OS version information: ', e);
}
}
export function getOSVersionSync(path: string): string | undefined {
export async function getOSVersion(path: string): Promise<string | undefined> {
return getOSReleaseField(path, 'PRETTY_NAME');
}
export function getOSVersion(path: string): Bluebird<string | undefined> {
return Bluebird.try(() => {
return getOSVersionSync(path);
});
}
export function getOSVariantSync(path: string): string | undefined {
export function getOSVariant(path: string): Promise<string | undefined> {
return getOSReleaseField(path, 'VARIANT_ID');
}
export function getOSVariant(path: string): Bluebird<string | undefined> {
return Bluebird.try(() => {
return getOSVariantSync(path);
});
export function getOSSemver(path: string): Promise<string | undefined> {
return getOSReleaseField(path, 'VERSION');
}

View File

@ -1,4 +1,5 @@
import * as Bluebird from 'bluebird';
import once = require('lodash/once');
import * as requestLib from 'request';
import * as resumableRequestLib from 'resumable-request';
@ -9,18 +10,6 @@ import supervisorVersion = require('./supervisor-version');
export { requestLib };
const osVersion = osRelease.getOSVersionSync(constants.hostOSVersionPath);
const osVariant = osRelease.getOSVariantSync(constants.hostOSVersionPath);
let userAgent = `Supervisor/${supervisorVersion}`;
if (osVersion != null) {
if (osVariant != null) {
userAgent += ` (Linux; ${osVersion}; ${osVariant})`;
} else {
userAgent += ` (Linux; ${osVersion})`;
}
}
// With these settings, the device must be unable to receive a single byte
// from the network for a continuous period of 20 minutes before we give up.
// (reqTimeout + retryInterval) * retryCount / 1000ms / 60sec ~> minutes
@ -28,20 +17,6 @@ const DEFAULT_REQUEST_TIMEOUT = 30000; // ms
const DEFAULT_REQUEST_RETRY_INTERVAL = 10000; // ms
const DEFAULT_REQUEST_RETRY_COUNT = 30;
export const requestOpts: requestLib.CoreOptions = {
gzip: true,
timeout: DEFAULT_REQUEST_TIMEOUT,
headers: {
'User-Agent': userAgent,
},
};
const resumableOpts = {
timeout: DEFAULT_REQUEST_TIMEOUT,
maxRetries: DEFAULT_REQUEST_RETRY_COUNT,
retryInterval: DEFAULT_REQUEST_RETRY_INTERVAL,
};
type PromisifiedRequest = typeof requestLib & {
postAsync: (
uri: string | requestLib.CoreOptions,
@ -53,9 +28,55 @@ type PromisifiedRequest = typeof requestLib & {
) => Bluebird<any>;
};
const requestHandle = requestLib.defaults(exports.requestOpts);
const getRequestInstances = once(async () => {
// Generate the user agents with out versions
const osVersion = await osRelease.getOSVersion(constants.hostOSVersionPath);
const osVariant = await osRelease.getOSVariant(constants.hostOSVersionPath);
let userAgent = `Supervisor/${supervisorVersion}`;
if (osVersion != null) {
if (osVariant != null) {
userAgent += ` (Linux; ${osVersion}; ${osVariant})`;
} else {
userAgent += ` (Linux; ${osVersion})`;
}
}
export const request = Bluebird.promisifyAll(requestHandle, {
multiArgs: true,
}) as PromisifiedRequest;
export const resumable = resumableRequestLib.defaults(resumableOpts);
const requestOpts: requestLib.CoreOptions = {
gzip: true,
timeout: DEFAULT_REQUEST_TIMEOUT,
headers: {
'User-Agent': userAgent,
},
};
const resumableOpts = {
timeout: DEFAULT_REQUEST_TIMEOUT,
maxRetries: DEFAULT_REQUEST_RETRY_COUNT,
retryInterval: DEFAULT_REQUEST_RETRY_INTERVAL,
};
const requestHandle = requestLib.defaults(exports.requestOpts);
const request = Bluebird.promisifyAll(requestHandle, {
multiArgs: true,
}) as PromisifiedRequest;
const resumable = resumableRequestLib.defaults(resumableOpts);
return {
requestOpts,
request,
resumable,
};
});
export const getRequestInstance = once(async () => {
return (await getRequestInstances()).request;
});
export const getRequestOptions = once(async () => {
return (await getRequestInstances()).requestOpts;
});
export const getResumableRequest = once(async () => {
return (await getRequestInstances()).resumable;
});

View File

@ -595,10 +595,12 @@ module.exports = class Proxyvisor
"#{constants.proxyvisorHookReceiver}/v1/devices/"
sendUpdate: (device, timeout, endpoint) =>
request.putAsync "#{endpoint}#{device.uuid}", {
json: true
body: device.target
}
Promise.resolve(request.getRequestInstance())
.then (instance) ->
instance.putAsync "#{endpoint}#{device.uuid}", {
json: true
body: device.target
}
.timeout(timeout)
.spread (response, body) =>
if response.statusCode == 200
@ -610,7 +612,9 @@ module.exports = class Proxyvisor
return log.error("Error updating device #{device.uuid}", err)
sendDeleteHook: ({ uuid }, timeout, endpoint) =>
request.delAsync("#{endpoint}#{uuid}")
Promise.resolve(request.getRequestInstance())
.then (instance) ->
instance.delAsync("#{endpoint}#{uuid}")
.timeout(timeout)
.spread (response, body) =>
if response.statusCode == 200

View File

@ -51,6 +51,7 @@ export class Supervisor {
db: this.db,
deviceState: this.deviceState,
eventTracker: this.eventTracker,
logger: this.logger,
});
// FIXME: rearchitect proxyvisor to avoid this circular dependency

80
src/target-state.ts Normal file
View File

@ -0,0 +1,80 @@
import * as _ from 'lodash';
import ApplicationManager from './application-manager';
import Config from './config';
import Database, { Transaction } from './db';
// Once we have correct types for both applications and the
// incoming target state this should be changed
export type DatabaseApp = Dictionary<any>;
export type DatabaseApps = DatabaseApp[];
/*
* This class is a wrapper around the database setting and
* receiving of target state. Because the target state can
* only be set from a single place, but several workflows
* rely on getting the target state at one point or another,
* we cache the values using this class. Accessing the
* database is inherently expensive, and for example the
* local log backend accesses the target state for every log
* line. This can very quickly cause serious memory problems
* and database connection timeouts.
*/
export class ApplicationTargetStateWrapper {
private targetState?: DatabaseApps;
public constructor(
protected applications: ApplicationManager,
protected config: Config,
protected db: Database,
) {
// If we switch backend, the target state also needs to
// be invalidated (this includes switching to and from
// local mode)
this.config.on('change', conf => {
if (conf.apiEndpoint != null || conf.localMode != null) {
this.targetState = undefined;
}
});
}
public async getTargetApp(appId: number): Promise<DatabaseApp | undefined> {
if (this.targetState == null) {
// TODO: Perhaps only fetch a single application from
// the DB, at the expense of repeating code
await this.getTargetApps();
}
return _.find(this.targetState, app => app.appId === appId);
}
public async getTargetApps(): Promise<DatabaseApp> {
if (this.targetState == null) {
const { apiEndpoint, localMode } = await this.config.getMany([
'apiEndpoint',
'localMode',
]);
const source = localMode ? 'local' : apiEndpoint;
this.targetState = await this.db.models('app').where({ source });
}
return this.targetState!;
}
public async setTargetApps(
apps: DatabaseApps,
trx: Transaction,
): Promise<void> {
// We can't cache the value here, as it could be for a
// different source
this.targetState = undefined;
await Promise.all(
apps.map(app =>
this.db.upsertModel('app', app, { appId: app.appId }, trx),
),
);
}
}
export default ApplicationTargetStateWrapper;

307
test/24-contracts.ts Normal file
View File

@ -0,0 +1,307 @@
import { assert, expect } from 'chai';
import * as semver from 'semver';
import * as constants from '../src/lib/constants';
import {
containerContractsFulfilled,
validateContract,
} from '../src/lib/contracts';
import * as osRelease from '../src/lib/os-release';
import supervisorVersion = require('../src/lib/supervisor-version');
describe('Container contracts', () => {
describe('Contract validation', () => {
it('should correctly validate a contract with no requirements', () => {
assert(
validateContract({
slug: 'user-container',
}),
);
});
it('should correctly validate a contract with extra fields', () => {
assert(
validateContract({
slug: 'user-container',
name: 'user-container',
version: '3.0.0',
}),
);
});
it('should not validate a contract without the minimum required fields', () => {
expect(() => {
validateContract({});
}).to.throw();
expect(() => {
validateContract({ name: 'test' });
}).to.throw();
expect(() => {
validateContract({ requires: [] });
}).to.throw();
});
it('should correctly validate a contract with requirements', () => {
assert(
validateContract({
slug: 'user-container',
requires: [
{
type: 'sw.os',
version: '>3.0.0',
},
{
type: 'sw.supervisor',
},
],
}),
);
});
it('should not validate a contract with requirements without the minimum required fields', () => {
expect(() =>
validateContract({
slug: 'user-container',
requires: [
{
version: '>3.0.0',
},
],
}),
).to.throw();
});
});
describe('Requirement resolution', () => {
// Because the supervisor version will change whenever the
// package.json will, we generate values which are above
// and below the current value, and use these to reason
// about the contract engine results
const supervisorVersionGreater = `${semver.major(supervisorVersion)! +
1}.0.0`;
const supervisorVersionLesser = `${semver.major(supervisorVersion)! -
1}.0.0`;
before(async () => {
// We ensure that the versions we're using for testing
// are the same as the time of implementation, otherwise
// these tests could fail or succeed when they shouldn't
expect(await osRelease.getOSSemver(constants.hostOSVersionPath)).to.equal(
'2.0.6',
);
assert(semver.gt(supervisorVersionGreater, supervisorVersion));
assert(semver.lt(supervisorVersionLesser, supervisorVersion));
});
it('Should correctly run containers with no requirements', async () => {
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
slug: 'user-container',
},
}),
)
.to.have.property('valid')
.that.equals(true);
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
slug: 'user-container1',
},
service2: {
type: 'sw.container',
slug: 'user-container2',
},
}),
)
.to.have.property('valid')
.that.equals(true);
});
it('should correctly run containers whose requirements are satisfied', async () => {
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container',
slug: 'user-container',
requires: [
{
type: 'sw.os',
version: '>2.0.0',
},
],
},
}),
)
.to.have.property('valid')
.that.equals(true);
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container',
slug: 'user-container',
requires: [
{
type: 'sw.supervisor',
version: `<${supervisorVersionGreater}`,
},
],
},
}),
)
.to.have.property('valid')
.that.equals(true);
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container',
slug: 'user-container',
requires: [
{
type: 'sw.supervisor',
version: `>${supervisorVersionLesser}`,
},
],
},
}),
)
.to.have.property('valid')
.that.equals(true);
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container',
slug: 'user-container',
requires: [
{
type: 'sw.supervisor',
version: `>${supervisorVersionLesser}`,
},
{
type: 'sw.os',
version: '<3.0.0',
},
],
},
}),
)
.to.have.property('valid')
.that.equals(true);
expect(
await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container1',
slug: 'user-container1',
requires: [
{
type: 'sw.supervisor',
version: `>${supervisorVersionLesser}`,
},
],
},
service2: {
type: 'sw.container',
name: 'user-container1',
slug: 'user-container1',
requires: [
{
type: 'sw.os',
version: '<3.0.0',
},
],
},
}),
)
.to.have.property('valid')
.that.equals(true);
});
it('Should refuse to run containers whose requirements are not satisfied', async () => {
let fulfilled = await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container',
slug: 'user-container',
requires: [
{
type: 'sw.os',
version: '>=3.0.0',
},
],
},
});
expect(fulfilled)
.to.have.property('valid')
.that.equals(false);
expect(fulfilled)
.to.have.property('unmetServices')
.that.deep.equals(['service']);
fulfilled = await containerContractsFulfilled({
service2: {
type: 'sw.container',
name: 'user-container2',
slug: 'user-container2',
requires: [
{
type: 'sw.supervisor',
version: `>=${supervisorVersionLesser}`,
},
{
type: 'sw.os',
version: '>3.0.0',
},
],
},
});
expect(fulfilled)
.to.have.property('valid')
.that.equals(false);
expect(fulfilled)
.to.have.property('unmetServices')
.that.deep.equals(['service2']);
fulfilled = await containerContractsFulfilled({
service: {
type: 'sw.container',
name: 'user-container1',
slug: 'user-container1',
requires: [
{
type: 'sw.supervisor',
version: `>=${supervisorVersionLesser}`,
},
],
},
service2: {
type: 'sw.container',
name: 'user-container2',
slug: 'user-container2',
requires: [
{
type: 'sw.supervisor',
version: `<=${supervisorVersionLesser}`,
},
],
},
});
expect(fulfilled)
.to.have.property('valid')
.that.equals(false);
expect(fulfilled)
.to.have.property('unmetServices')
.that.deep.equals(['service2']);
});
});
});

View File

@ -1,2 +1,3 @@
PRETTY_NAME="Resin OS 2.0.6 (fake)"
PRETTY_NAME="balenaOS 2.0.6 (fake)"
VARIANT_ID="dev"
VERSION="2.0.6"

View File

@ -1 +1,2 @@
PRETTY_NAME="Resin OS 2.0.6 (fake)"
PRETTY_NAME="balenaOS 2.0.6 (fake)"
VERSION="2.0.6"