Add support for container contracts

These contracts can specify an OS version and supervisor version that
they require. If any of the containers in a release have requirements
that are not met, the release is rejected, and the previous release
continues to run.

Change-type: minor
Closes: #1086
Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
Cameron Diver
2019-08-28 11:38:50 +01:00
parent 9843f62e24
commit 14e442f943
10 changed files with 417 additions and 158 deletions

8
package-lock.json generated
View File

@ -4511,12 +4511,14 @@
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@ -4536,7 +4538,8 @@
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
@ -4684,6 +4687,7 @@
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }

View File

@ -16,6 +16,8 @@ import { EventTracker } from './event-tracker';
import * as constants from './lib/constants'; import * as constants from './lib/constants';
import { import {
ContractValidationError,
ContractViolationError,
DuplicateUuidError, DuplicateUuidError,
ExchangeKeyError, ExchangeKeyError,
InternalInconsistencyError, InternalInconsistencyError,
@ -28,6 +30,7 @@ import { DeviceApplicationState } from './types/state';
import log from './lib/supervisor-console'; import log from './lib/supervisor-console';
import DeviceState = require('./device-state'); import DeviceState = require('./device-state');
import Logger from './logger';
const REPORT_SUCCESS_DELAY = 1000; const REPORT_SUCCESS_DELAY = 1000;
const MAX_REPORT_RETRY_DELAY = 60000; const MAX_REPORT_RETRY_DELAY = 60000;
@ -44,6 +47,7 @@ export interface APIBinderConstructOpts {
db: Database; db: Database;
deviceState: DeviceState; deviceState: DeviceState;
eventTracker: EventTracker; eventTracker: EventTracker;
logger: Logger;
} }
interface Device { interface Device {
@ -74,6 +78,7 @@ export class APIBinder {
[key: string]: any; [key: string]: any;
}; };
private eventTracker: EventTracker; private eventTracker: EventTracker;
private logger: Logger;
public balenaApi: PinejsClientRequest | null = null; public balenaApi: PinejsClientRequest | null = null;
private cachedBalenaApi: PinejsClientRequest | null = null; private cachedBalenaApi: PinejsClientRequest | null = null;
@ -96,10 +101,12 @@ export class APIBinder {
config, config,
deviceState, deviceState,
eventTracker, eventTracker,
logger,
}: APIBinderConstructOpts) { }: APIBinderConstructOpts) {
this.config = config; this.config = config;
this.deviceState = deviceState; this.deviceState = deviceState;
this.eventTracker = eventTracker; this.eventTracker = eventTracker;
this.logger = logger;
this.router = this.createAPIBinderRouter(this); this.router = this.createAPIBinderRouter(this);
} }
@ -580,9 +587,25 @@ export class APIBinder {
this.deviceState.triggerApplyTarget({ force, isFromApi }); this.deviceState.triggerApplyTarget({ force, isFromApi });
} }
}) })
.tapCatch(err => { .tapCatch(ContractValidationError, ContractViolationError, e => {
log.error(`Failed to get target state for device: ${err}`); 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(() => { .finally(() => {
this.lastTargetStateFetch = process.hrtime(); this.lastTargetStateFetch = process.hrtime();
}); });

View File

@ -9,11 +9,12 @@ path = require 'path'
constants = require './lib/constants' constants = require './lib/constants'
{ log } = require './lib/supervisor-console' { log } = require './lib/supervisor-console'
{ containerContractsFulfilled, validateContract } = require './lib/contracts'
{ DockerUtils: Docker } = require './lib/docker-utils' { DockerUtils: Docker } = require './lib/docker-utils'
{ LocalModeManager } = require './local-mode' { LocalModeManager } = require './local-mode'
updateLock = require './lib/update-lock' updateLock = require './lib/update-lock'
{ checkTruthy, checkInt, checkString } = require './lib/validation' { checkTruthy, checkInt, checkString } = require './lib/validation'
{ NotFoundError } = require './lib/errors' { ContractViolationError, ContractValidationError, NotFoundError } = require './lib/errors'
{ pathExistsOnHost } = require './lib/fs-utils' { pathExistsOnHost } = require './lib/fs-utils'
{ ApplicationTargetStateWrapper } = require './target-state' { ApplicationTargetStateWrapper } = require './target-state'
@ -683,9 +684,31 @@ module.exports = class ApplicationManager extends EventEmitter
) )
setTarget: (apps, dependent , source, trx) => 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 => Promise.try =>
appsArray = _.map apps, (app, appId) -> appsArray = _.map filteredApps, (app, appId) ->
appClone = _.clone(app) appClone = _.clone(app)
appClone.appId = checkInt(appId) appClone.appId = checkInt(appId)
appClone.source = source appClone.source = source
@ -694,17 +717,34 @@ module.exports = class ApplicationManager extends EventEmitter
.tap (appsForDB) => .tap (appsForDB) =>
@targetStateWrapper.setTargetApps(appsForDB, trx) @targetStateWrapper.setTargetApps(appsForDB, trx)
.then (appsForDB) -> .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 => .then =>
@proxyvisor.setTargetInTransaction(dependent, trx) @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? if trx?
setInTransaction(trx) setInTransaction(filteredApps, trx)
else else
@db.transaction(setInTransaction) @db.transaction(setInTransaction)
.then => .then =>
@_targetVolatilePerImageId = {} @_targetVolatilePerImageId = {}
.finally ->
if not _.isEmpty(contractViolators)
throw new ContractViolationError(contractViolators)
setTargetVolatileForService: (imageId, target) => setTargetVolatileForService: (imageId, target) =>
@_targetVolatilePerImageId[imageId] ?= {} @_targetVolatilePerImageId[imageId] ?= {}
@ -933,6 +973,7 @@ module.exports = class ApplicationManager extends EventEmitter
conf = { delta, localMode } conf = { delta, localMode }
if conf.localMode if conf.localMode
cleanupNeeded = false cleanupNeeded = false
@_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf, containerIds) @_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf, containerIds)
.then (nextSteps) => .then (nextSteps) =>
if ignoreImages and _.some(nextSteps, action: 'fetch') if ignoreImages and _.some(nextSteps, action: 'fetch')

View File

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

View File

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

View File

@ -1,3 +1,6 @@
import { isLeft } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import { reporter } from 'io-ts-reporters';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { Blueprint, Contract, ContractObject } from '@balena/contrato'; import { Blueprint, Contract, ContractObject } from '@balena/contrato';
@ -7,9 +10,17 @@ import { InternalInconsistencyError } from './errors';
import * as osRelease from './os-release'; import * as osRelease from './os-release';
import supervisorVersion = require('./supervisor-version'); import supervisorVersion = require('./supervisor-version');
export { ContractObject };
export interface ServiceContracts {
[serviceName: string]: ContractObject;
}
export async function containerContractsFulfilled( export async function containerContractsFulfilled(
containers: ContractObject[], serviceContracts: ServiceContracts,
): Promise<boolean> { ): Promise<{ valid: boolean; unmetServices: string[] }> {
const containers = _.values(serviceContracts);
const osContract = new Contract({ const osContract = new Contract({
slug: 'balenaOS', slug: 'balenaOS',
type: 'sw.os', type: 'sw.os',
@ -54,7 +65,7 @@ export async function containerContractsFulfilled(
); );
} }
if (solution.length === 0) { if (solution.length === 0) {
return false; return { valid: false, unmetServices: _.keys(serviceContracts) };
} }
// Detect how many containers are present in the resulting // Detect how many containers are present in the resulting
@ -62,5 +73,50 @@ export async function containerContractsFulfilled(
const children = solution[0].getChildren({ const children = solution[0].getChildren({
types: new Set(['sw.container']), types: new Set(['sw.container']),
}); });
return children.length === containers.length;
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

@ -1,4 +1,4 @@
import { endsWith, startsWith } from 'lodash'; import { endsWith, map, startsWith } from 'lodash';
import TypedError = require('typed-error'); import TypedError = require('typed-error');
import { checkInt } from './validation'; import { checkInt } from './validation';
@ -68,3 +68,36 @@ export class ImageAuthenticationError extends TypedError {}
* See LocalModeManager for a usage example. * See LocalModeManager for a usage example.
*/ */
export class SupervisorContainerNotFoundError extends TypedError {} 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,4 +1,3 @@
import * as Bluebird from 'bluebird';
import * as _ from 'lodash'; import * as _ from 'lodash';
import fs = require('mz/fs'); import fs = require('mz/fs');

View File

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

View File

@ -1,20 +1,87 @@
import { assert, expect } from 'chai'; import { assert, expect } from 'chai';
import * as semver from 'balena-semver'; import * as semver from 'semver';
import * as constants from '../src/lib/constants'; import * as constants from '../src/lib/constants';
import { containerContractsFulfilled } from '../src/lib/contracts'; import {
containerContractsFulfilled,
validateContract,
} from '../src/lib/contracts';
import * as osRelease from '../src/lib/os-release'; import * as osRelease from '../src/lib/os-release';
import supervisorVersion = require('../src/lib/supervisor-version'); import supervisorVersion = require('../src/lib/supervisor-version');
describe('Container contracts', () => { 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 // Because the supervisor version will change whenever the
// package.json will, we generate values which are above // package.json will, we generate values which are above
// and below the current value, and use these to reason // and below the current value, and use these to reason
// about the contract engine results // about the contract engine results
const supervisorVersionGreater = `${semver.major(supervisorVersion)! + const supervisorVersionGreater = `${semver.major(supervisorVersion)! +
1}.0.0`; 1}.0.0`;
const supervisorVersionLesser = `${semver.major(supervisorVersion)! - 1}.0.0`; const supervisorVersionLesser = `${semver.major(supervisorVersion)! -
1}.0.0`;
before(async () => { before(async () => {
// We ensure that the versions we're using for testing // We ensure that the versions we're using for testing
@ -29,34 +96,35 @@ describe('Container contracts', () => {
it('Should correctly run containers with no requirements', async () => { it('Should correctly run containers with no requirements', async () => {
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container',
slug: 'user-container', slug: 'user-container',
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container',
slug: 'user-container1', slug: 'user-container1',
}, },
{ service2: {
type: 'sw.container', type: 'sw.container',
name: 'user-container',
slug: 'user-container2', slug: 'user-container2',
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
}); });
it('should correctly run containers whose requirements are satisfied', async () => { it('should correctly run containers whose requirements are satisfied', async () => {
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
slug: 'user-container', slug: 'user-container',
@ -67,12 +135,14 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
slug: 'user-container', slug: 'user-container',
@ -83,11 +153,14 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
slug: 'user-container', slug: 'user-container',
@ -98,11 +171,14 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
slug: 'user-container', slug: 'user-container',
@ -117,11 +193,13 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
expect( expect(
await containerContractsFulfilled([ await containerContractsFulfilled({
{ service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container1', name: 'user-container1',
slug: 'user-container1', slug: 'user-container1',
@ -132,7 +210,7 @@ describe('Container contracts', () => {
}, },
], ],
}, },
{ service2: {
type: 'sw.container', type: 'sw.container',
name: 'user-container1', name: 'user-container1',
slug: 'user-container1', slug: 'user-container1',
@ -143,14 +221,15 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), }),
).to.equal(true); )
.to.have.property('valid')
.that.equals(true);
}); });
it('Should refuse to run containers whose requirements are not satisfied', async () => { it('Should refuse to run containers whose requirements are not satisfied', async () => {
expect( let fulfilled = await containerContractsFulfilled({
await containerContractsFulfilled([ service: {
{
type: 'sw.container', type: 'sw.container',
name: 'user-container', name: 'user-container',
slug: 'user-container', slug: 'user-container',
@ -161,11 +240,16 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), });
).to.equal(false); expect(fulfilled)
expect( .to.have.property('valid')
await containerContractsFulfilled([ .that.equals(false);
{ expect(fulfilled)
.to.have.property('unmetServices')
.that.deep.equals(['service']);
fulfilled = await containerContractsFulfilled({
service2: {
type: 'sw.container', type: 'sw.container',
name: 'user-container2', name: 'user-container2',
slug: 'user-container2', slug: 'user-container2',
@ -180,11 +264,16 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), });
).to.equal(false); expect(fulfilled)
expect( .to.have.property('valid')
await containerContractsFulfilled([ .that.equals(false);
{ expect(fulfilled)
.to.have.property('unmetServices')
.that.deep.equals(['service2']);
fulfilled = await containerContractsFulfilled({
service: {
type: 'sw.container', type: 'sw.container',
name: 'user-container1', name: 'user-container1',
slug: 'user-container1', slug: 'user-container1',
@ -195,7 +284,7 @@ describe('Container contracts', () => {
}, },
], ],
}, },
{ service2: {
type: 'sw.container', type: 'sw.container',
name: 'user-container2', name: 'user-container2',
slug: 'user-container2', slug: 'user-container2',
@ -206,7 +295,13 @@ describe('Container contracts', () => {
}, },
], ],
}, },
]), });
).to.equal(false); expect(fulfilled)
.to.have.property('valid')
.that.equals(false);
expect(fulfilled)
.to.have.property('unmetServices')
.that.deep.equals(['service2']);
});
}); });
}); });