Merge pull request #1127 from balena-io/contextual-containers

Support optional containers based on their contract
This commit is contained in:
CameronDiver 2019-11-05 15:02:06 +00:00 committed by GitHub
commit 474b37903c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 295 additions and 136 deletions

View File

@ -689,7 +689,8 @@ module.exports = class ApplicationManager extends EventEmitter
# to the database, overwriting the current release. This # to the database, overwriting the current release. This
# is because if we just reject the release, but leave it # is because if we just reject the release, but leave it
# in the db, if for any reason the current state stops # in the db, if for any reason the current state stops
# running, we won't restart it, leaving the device useless # running, we won't restart it, leaving the device
# useless
contractsFulfilled = _.mapValues apps, (app) -> contractsFulfilled = _.mapValues apps, (app) ->
serviceContracts = {} serviceContracts = {}
_.each app.services, (s) -> _.each app.services, (s) ->
@ -698,12 +699,16 @@ module.exports = class ApplicationManager extends EventEmitter
validateContract(s.contract) validateContract(s.contract)
catch e catch e
throw new ContractValidationError(s.serviceName, e.message) throw new ContractValidationError(s.serviceName, e.message)
serviceContracts[s.serviceName] = s.contract serviceContracts[s.serviceName] =
contract: s.contract,
optional: checkTruthy(s.labels['io.balena.features.optional']) ? false
else
serviceContracts[s.serviceName] = { contract: null, optional: false }
if !_.isEmpty(serviceContracts) if !_.isEmpty(serviceContracts)
containerContractsFulfilled(serviceContracts) containerContractsFulfilled(serviceContracts)
else else
{ valid: true } { valid: true, fulfilledServices: _.map(app.services, 'serviceName') }
setInTransaction = (filteredApps, trx) => setInTransaction = (filteredApps, trx) =>
@ -730,12 +735,23 @@ module.exports = class ApplicationManager extends EventEmitter
@proxyvisor.setTargetInTransaction(dependent, trx) @proxyvisor.setTargetInTransaction(dependent, trx)
contractViolators = {} contractViolators = {}
Promise.props(contractsFulfilled).then (fulfilledContracts) -> Promise.props(contractsFulfilled).then (fulfilledContracts) =>
filteredApps = _.cloneDeep(apps) filteredApps = _.cloneDeep(apps)
_.each fulfilledContracts, ({ valid, unmetServices }, appId) -> _.each(
if not valid fulfilledContracts,
contractViolators[apps[appId].name] = unmetServices ({ valid, unmetServices, fulfilledServices, unmetAndOptional }, appId) =>
delete filteredApps[appId] if not valid
contractViolators[apps[appId].name] = unmetServices
delete filteredApps[appId]
else
# valid is true, but we could still be missing
# some optional containers, and need to filter
# these out of the target state
filteredApps[appId].services = _.pickBy filteredApps[appId].services, ({ serviceName }) ->
fulfilledServices.includes(serviceName)
if unmetAndOptional.length != 0
@reportOptionalContainers(unmetAndOptional)
)
if trx? if trx?
setInTransaction(filteredApps, trx) setInTransaction(filteredApps, trx)
else else
@ -997,3 +1013,12 @@ module.exports = class ApplicationManager extends EventEmitter
return volumes.map((v) -> { action: 'removeVolume', current: v }) return volumes.map((v) -> { action: 'removeVolume', current: v })
localModeSwitchCompletion: => @localModeManager.switchCompletion() localModeSwitchCompletion: => @localModeManager.switchCompletion()
reportOptionalContainers: (serviceNames) =>
# Print logs to the console and dashboard, letting the
# user know that we're not going to run certain services
# because of their contract
message = "Not running containers because of contract violations: #{serviceNames.join('. ')}"
log.info(message)
@logger.logSystemMessage(message, {}, 'optionalContainerViolation', true)

View File

@ -13,13 +13,21 @@ import supervisorVersion = require('./supervisor-version');
export { ContractObject }; export { ContractObject };
export interface ServiceContracts { export interface ServiceContracts {
[serviceName: string]: ContractObject; [serviceName: string]: { contract?: ContractObject; optional: boolean };
} }
export async function containerContractsFulfilled( export async function containerContractsFulfilled(
serviceContracts: ServiceContracts, serviceContracts: ServiceContracts,
): Promise<{ valid: boolean; unmetServices: string[] }> { ): Promise<{
const containers = _.values(serviceContracts); valid: boolean;
unmetServices: string[];
fulfilledServices: string[];
unmetAndOptional: string[];
}> {
const containers = _(serviceContracts)
.map('contract')
.compact()
.value();
const osContract = new Contract({ const osContract = new Contract({
slug: 'balenaOS', slug: 'balenaOS',
@ -51,11 +59,11 @@ export async function containerContractsFulfilled(
type: 'meta.universe', type: 'meta.universe',
}); });
universe.addChildren( universe.addChildren([
[osContract, supervisorContract].concat( osContract,
containers.map(c => new Contract(c)), supervisorContract,
), ...containers.map(c => new Contract(c)),
); ]);
const solution = blueprint.reproduce(universe); const solution = blueprint.reproduce(universe);
@ -65,7 +73,12 @@ export async function containerContractsFulfilled(
); );
} }
if (solution.length === 0) { if (solution.length === 0) {
return { valid: false, unmetServices: _.keys(serviceContracts) }; return {
valid: false,
unmetServices: _.keys(serviceContracts),
fulfilledServices: [],
unmetAndOptional: [],
};
} }
// Detect how many containers are present in the resulting // Detect how many containers are present in the resulting
@ -75,23 +88,46 @@ export async function containerContractsFulfilled(
}); });
if (children.length === containers.length) { if (children.length === containers.length) {
return { valid: true, unmetServices: [] }; return {
valid: true,
unmetServices: [],
fulfilledServices: _.keys(serviceContracts),
unmetAndOptional: [],
};
} else { } else {
// Work out which service violated the contracts they // If we got here, it means that at least one of the
// provided // container contracts was not fulfilled. If *all* of
const unmetServices = _(serviceContracts) // those containers whose contract was not met are
.map((contract, serviceName) => { // marked as optional, the target state is still valid,
const found = _.find(children, child => { // but we ignore the optional containers
return _.isEqual((child as any).raw, contract);
}); const [fulfilledServices, unfulfilledServices] = _.partition(
if (found == null) { _.keys(serviceContracts),
return serviceName; serviceName => {
const { contract } = serviceContracts[serviceName];
if (!contract) {
return true;
} }
return; // Did we find the contract in the generated state?
}) return _.some(children, child =>
.filter(n => n != null) _.isEqual((child as any).raw, contract),
.value() as string[]; );
return { valid: false, unmetServices }; },
);
const [unmetAndRequired, unmetAndOptional] = _.partition(
unfulfilledServices,
serviceName => {
return !serviceContracts[serviceName].optional;
},
);
return {
valid: unmetAndRequired.length === 0,
unmetServices: unfulfilledServices,
fulfilledServices,
unmetAndOptional,
};
} }
} }

View File

@ -98,8 +98,11 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
slug: 'user-container', type: 'sw.container',
slug: 'user-container',
},
optional: false,
}, },
}), }),
) )
@ -108,12 +111,18 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
slug: 'user-container1', type: 'sw.container',
slug: 'user-container1',
},
optional: false,
}, },
service2: { service2: {
type: 'sw.container', contract: {
slug: 'user-container2', type: 'sw.container',
slug: 'user-container2',
},
optional: false,
}, },
}), }),
) )
@ -125,15 +134,18 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container', type: 'sw.container',
slug: 'user-container', name: 'user-container',
requires: [ slug: 'user-container',
{ requires: [
type: 'sw.os', {
version: '>2.0.0', type: 'sw.os',
}, version: '>2.0.0',
], },
],
},
optional: false,
}, },
}), }),
) )
@ -143,15 +155,18 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container', type: 'sw.container',
slug: 'user-container', name: 'user-container',
requires: [ slug: 'user-container',
{ requires: [
type: 'sw.supervisor', {
version: `<${supervisorVersionGreater}`, type: 'sw.supervisor',
}, version: `<${supervisorVersionGreater}`,
], },
],
},
optional: false,
}, },
}), }),
) )
@ -161,15 +176,18 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container', type: 'sw.container',
slug: 'user-container', name: 'user-container',
requires: [ slug: 'user-container',
{ requires: [
type: 'sw.supervisor', {
version: `>${supervisorVersionLesser}`, type: 'sw.supervisor',
}, version: `>${supervisorVersionLesser}`,
], },
],
},
optional: false,
}, },
}), }),
) )
@ -179,19 +197,22 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container', type: 'sw.container',
slug: 'user-container', name: 'user-container',
requires: [ slug: 'user-container',
{ requires: [
type: 'sw.supervisor', {
version: `>${supervisorVersionLesser}`, type: 'sw.supervisor',
}, version: `>${supervisorVersionLesser}`,
{ },
type: 'sw.os', {
version: '<3.0.0', type: 'sw.os',
}, version: '<3.0.0',
], },
],
},
optional: false,
}, },
}), }),
) )
@ -200,26 +221,32 @@ describe('Container contracts', () => {
expect( expect(
await containerContractsFulfilled({ await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container1', type: 'sw.container',
slug: 'user-container1', name: 'user-container1',
requires: [ slug: 'user-container1',
{ requires: [
type: 'sw.supervisor', {
version: `>${supervisorVersionLesser}`, type: 'sw.supervisor',
}, version: `>${supervisorVersionLesser}`,
], },
],
},
optional: false,
}, },
service2: { service2: {
type: 'sw.container', contract: {
name: 'user-container1', type: 'sw.container',
slug: 'user-container1', name: 'user-container1',
requires: [ slug: 'user-container1',
{ requires: [
type: 'sw.os', {
version: '<3.0.0', type: 'sw.os',
}, version: '<3.0.0',
], },
],
},
optional: false,
}, },
}), }),
) )
@ -230,15 +257,18 @@ describe('Container contracts', () => {
it('Should refuse to run containers whose requirements are not satisfied', async () => { it('Should refuse to run containers whose requirements are not satisfied', async () => {
let fulfilled = await containerContractsFulfilled({ let fulfilled = await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container', type: 'sw.container',
slug: 'user-container', name: 'user-container',
requires: [ slug: 'user-container',
{ requires: [
type: 'sw.os', {
version: '>=3.0.0', type: 'sw.os',
}, version: '>=3.0.0',
], },
],
},
optional: false,
}, },
}); });
expect(fulfilled) expect(fulfilled)
@ -250,19 +280,22 @@ describe('Container contracts', () => {
fulfilled = await containerContractsFulfilled({ fulfilled = await containerContractsFulfilled({
service2: { service2: {
type: 'sw.container', contract: {
name: 'user-container2', type: 'sw.container',
slug: 'user-container2', name: 'user-container2',
requires: [ slug: 'user-container2',
{ requires: [
type: 'sw.supervisor', {
version: `>=${supervisorVersionLesser}`, type: 'sw.supervisor',
}, version: `>=${supervisorVersionLesser}`,
{ },
type: 'sw.os', {
version: '>3.0.0', type: 'sw.os',
}, version: '>3.0.0',
], },
],
},
optional: false,
}, },
}); });
expect(fulfilled) expect(fulfilled)
@ -274,26 +307,32 @@ describe('Container contracts', () => {
fulfilled = await containerContractsFulfilled({ fulfilled = await containerContractsFulfilled({
service: { service: {
type: 'sw.container', contract: {
name: 'user-container1', type: 'sw.container',
slug: 'user-container1', name: 'user-container1',
requires: [ slug: 'user-container1',
{ requires: [
type: 'sw.supervisor', {
version: `>=${supervisorVersionLesser}`, type: 'sw.supervisor',
}, version: `>=${supervisorVersionLesser}`,
], },
],
},
optional: false,
}, },
service2: { service2: {
type: 'sw.container', contract: {
name: 'user-container2', type: 'sw.container',
slug: 'user-container2', name: 'user-container2',
requires: [ slug: 'user-container2',
{ requires: [
type: 'sw.supervisor', {
version: `<=${supervisorVersionLesser}`, type: 'sw.supervisor',
}, version: `<=${supervisorVersionLesser}`,
], },
],
},
optional: false,
}, },
}); });
expect(fulfilled) expect(fulfilled)
@ -303,5 +342,64 @@ describe('Container contracts', () => {
.to.have.property('unmetServices') .to.have.property('unmetServices')
.that.deep.equals(['service2']); .that.deep.equals(['service2']);
}); });
describe('Optional containers', () => {
it('should correctly run passing optional containers', async () => {
const {
valid,
unmetServices,
fulfilledServices,
} = await containerContractsFulfilled({
service1: {
contract: {
type: 'sw.container',
slug: 'service1',
requires: [
{
type: 'sw.os',
version: `<${supervisorVersionGreater}`,
},
],
},
optional: true,
},
});
expect(valid).to.equal(true);
expect(unmetServices).to.deep.equal([]);
expect(fulfilledServices).to.deep.equal(['service1']);
});
it('should corrrectly omit failing optional containers', async () => {
const {
valid,
unmetServices,
fulfilledServices,
} = await containerContractsFulfilled({
service1: {
contract: {
type: 'sw.container',
slug: 'service1',
requires: [
{
type: 'sw.os',
version: `>${supervisorVersionGreater}`,
},
],
},
optional: true,
},
service2: {
contract: {
type: 'sw.container',
slug: 'service2',
},
optional: false,
},
});
expect(valid).to.equal(true);
expect(unmetServices).to.deep.equal(['service1']);
expect(fulfilledServices).to.deep.equal(['service2']);
});
});
}); });
}); });