mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-19 19:28:59 +00:00
Merge pull request #1090 from balena-io/container-contracts
Add support for container contracts
This commit is contained in:
commit
7c9e2f4287
1128
package-lock.json
generated
1128
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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: {
|
||||
|
@ -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')
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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
122
src/lib/contracts.ts
Normal 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;
|
||||
}
|
@ -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];
|
||||
|
@ -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 ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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
80
src/target-state.ts
Normal 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
307
test/24-contracts.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
@ -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"
|
||||
|
@ -1 +1,2 @@
|
||||
PRETTY_NAME="Resin OS 2.0.6 (fake)"
|
||||
PRETTY_NAME="balenaOS 2.0.6 (fake)"
|
||||
VERSION="2.0.6"
|
||||
|
Loading…
Reference in New Issue
Block a user