mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-23 18:50:27 +00:00
Merge pull request #1127 from balena-io/contextual-containers
Support optional containers based on their contract
This commit is contained in:
commit
474b37903c
@ -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)
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user