Consider linux4tegra versions in container contracts

Also remove ability to match on OS versions

Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
Cameron Diver 2019-11-12 17:02:03 +00:00
parent bba6d3d728
commit 1a6c9d489a
No known key found for this signature in database
GPG Key ID: 49690ED87032539F
5 changed files with 243 additions and 105 deletions

View File

@ -12,11 +12,11 @@
"build": "webpack",
"build:debug": "npm run typescript:release && npm run coffeescript:release && npm run migrations:copy && npm run packagejson:copy",
"lint": "npm run lint:coffee && npm run lint:typescript",
"test": "npm run lint && npm run test:build && JUNIT_REPORT_PATH=report.xml istanbul cover _mocha && npm run coverage",
"test": "npm run lint && npm run test:build && TEST=1 JUNIT_REPORT_PATH=report.xml istanbul cover _mocha && npm run coverage",
"test:build": "npm run typescript:test-build && npm run coffeescript:test && npm run testitems:copy && npm run migrations:copy-test && npm run packagejson:copy",
"coverage": "istanbul report text && istanbul report html",
"test:fast": "mocha --opts test/fast-mocha.opts",
"test:debug": "npm run test:build && mocha --inspect-brk",
"test:fast": "TEST=1 mocha --opts test/fast-mocha.opts",
"test:debug": "npm run test:build && TEST=1 mocha --inspect-brk",
"prettify": "prettier --config ./node_modules/resin-lint/config/.prettierrc --write \"{src,test,typings}/**/*.ts\"",
"typescript:test-build": "tsc --project tsconfig.json",
"typescript:release": "tsc --project tsconfig.release.json && cp -r build/src/* build && rm -rf build/src",

View File

@ -9,12 +9,12 @@ path = require 'path'
constants = require './lib/constants'
{ log } = require './lib/supervisor-console'
{ containerContractsFulfilled, validateContract } = require './lib/contracts'
{ validateTargetContracts } = require './lib/contracts'
{ DockerUtils: Docker } = require './lib/docker-utils'
{ LocalModeManager } = require './local-mode'
updateLock = require './lib/update-lock'
{ checkTruthy, checkInt, checkString } = require './lib/validation'
{ ContractViolationError, ContractValidationError, NotFoundError } = require './lib/errors'
{ ContractViolationError, NotFoundError } = require './lib/errors'
{ pathExistsOnHost } = require './lib/fs-utils'
{ TargetStateAccessor } = require './target-state'
@ -683,34 +683,7 @@ module.exports = class ApplicationManager extends EventEmitter
return outApp
)
setTarget: (apps, dependent , source, 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.contract)
catch e
throw new ContractValidationError(s.serviceName, e.message)
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)
containerContractsFulfilled(serviceContracts)
else
{ valid: true, fulfilledServices: _.map(app.services, 'serviceName') }
setTarget: (apps, dependent , source, trx) ->
setInTransaction = (filteredApps, trx) =>
Promise.try =>
appsArray = _.map filteredApps, (app, appId) ->
@ -734,8 +707,18 @@ module.exports = class ApplicationManager extends EventEmitter
.then =>
@proxyvisor.setTargetInTransaction(dependent, 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 - The exception to this rule is when the only
# failing services are marked as optional, then we
# filter those out and add the target state to the database
contractViolators = {}
Promise.props(contractsFulfilled).then (fulfilledContracts) =>
Promise.resolve(validateTargetContracts(apps))
.then (fulfilledContracts) =>
filteredApps = _.cloneDeep(apps)
_.each(
fulfilledContracts,

View File

@ -5,48 +5,84 @@ import * as _ from 'lodash';
import { Blueprint, Contract, ContractObject } from '@balena/contrato';
import constants = require('./constants');
import { InternalInconsistencyError } from './errors';
import { ContractValidationError, InternalInconsistencyError } from './errors';
import * as osRelease from './os-release';
import supervisorVersion = require('./supervisor-version');
import { checkTruthy } from './validation';
export { ContractObject };
// TODO{type}: When target and current state are correctly
// defined, replace this
interface AppWithContracts {
services: {
[key: string]: {
serviceName: string;
contract?: ContractObject;
labels?: Dictionary<string>;
};
};
}
export interface ApplicationContractResult {
valid: boolean;
unmetServices: string[];
fulfilledServices: string[];
unmetAndOptional: string[];
}
export interface ServiceContracts {
[serviceName: string]: { contract?: ContractObject; optional: boolean };
}
type PotentialContractRequirements = 'sw.supervisor' | 'sw.l4t';
type ContractRequirementVersions = {
[key in PotentialContractRequirements]?: string;
};
let contractRequirementVersions = async () => {
const versions: ContractRequirementVersions = {
'sw.supervisor': supervisorVersion,
};
const l4tVersion = await osRelease.getL4tVersion();
if (l4tVersion != null) {
versions['sw.l4t'] = l4tVersion;
}
return versions;
};
// When running in tests, we need this function to be
// repeatedly executed, but on-device, this should only be
// executed once per run
if (process.env.TEST !== '1') {
contractRequirementVersions = _.once(contractRequirementVersions);
}
function isValidRequirementType(
requirementVersions: ContractRequirementVersions,
requirement: string,
) {
return requirement in requirementVersions;
}
export async function containerContractsFulfilled(
serviceContracts: ServiceContracts,
): Promise<{
valid: boolean;
unmetServices: string[];
fulfilledServices: string[];
unmetAndOptional: string[];
}> {
): Promise<ApplicationContractResult> {
const containers = _(serviceContracts)
.map('contract')
.compact()
.value();
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 versions = await contractRequirementVersions();
const blueprintMembership: Dictionary<number> = {};
for (const component of _.keys(versions)) {
blueprintMembership[component] = 1;
}
const blueprint = new Blueprint(
{
'sw.os': 1,
'sw.supervisor': 1,
...blueprintMembership,
'sw.container': '1+',
},
{
@ -59,11 +95,11 @@ export async function containerContractsFulfilled(
type: 'meta.universe',
});
universe.addChildren([
osContract,
supervisorContract,
...containers.map(c => new Contract(c)),
]);
universe.addChildren(
[...getContractsFromVersions(versions), ...containers].map(
c => new Contract(c),
),
);
const solution = blueprint.reproduce(universe);
@ -145,14 +181,76 @@ const contractObjectValidator = t.type({
]),
});
export function validateContract(
contract: unknown,
): contract is ContractObject {
function getContractsFromVersions(versions: ContractRequirementVersions) {
return _.map(versions, (version, component) => ({
type: component,
slug: component,
name: component,
version,
}));
}
export async function validateContract(contract: unknown): Promise<boolean> {
const result = contractObjectValidator.decode(contract);
if (isLeft(result)) {
throw new Error(reporter(result).join('\n'));
}
const requirementVersions = await contractRequirementVersions();
for (const { type } of result.right.requires || []) {
if (!isValidRequirementType(requirementVersions, type)) {
throw new Error(`${type} is not a valid contract requirement type`);
}
}
return true;
}
export async function validateTargetContracts(
apps: Dictionary<AppWithContracts>,
): Promise<Dictionary<ApplicationContractResult>> {
const appsFulfilled: Dictionary<ApplicationContractResult> = {};
for (const appId of _.keys(apps)) {
const app = apps[appId];
const serviceContracts: ServiceContracts = {};
for (const svcId of _.keys(app.services)) {
const svc = app.services[svcId];
if (svc.contract) {
try {
await validateContract(svc.contract);
serviceContracts[svc.serviceName] = {
contract: svc.contract,
optional:
checkTruthy(svc.labels?.['io.balena.features.optional']) || false,
};
} catch (e) {
throw new ContractValidationError(svc.serviceName, e.message);
}
} else {
serviceContracts[svc.serviceName] = {
contract: undefined,
optional: false,
};
}
if (!_.isEmpty(serviceContracts)) {
appsFulfilled[appId] = await containerContractsFulfilled(
serviceContracts,
);
} else {
appsFulfilled[appId] = {
valid: true,
fulfilledServices: _.map(app.services, 'serviceName'),
unmetAndOptional: [],
unmetServices: [],
};
}
}
}
return appsFulfilled;
}

View File

@ -1,5 +1,5 @@
import * as _ from 'lodash';
import fs = require('mz/fs');
import { child_process, fs } from 'mz';
import { InternalInconsistencyError } from './errors';
import log from './supervisor-console';
@ -58,3 +58,20 @@ export function getOSVariant(path: string): Promise<string | undefined> {
export function getOSSemver(path: string): Promise<string | undefined> {
return getOSReleaseField(path, 'VERSION');
}
const L4T_REGEX = /^.*-l4t-r(\d+\.\d+).*$/;
export async function getL4tVersion(): Promise<string | undefined> {
// We call `uname -r` on the host, and look for l4t
try {
const [stdout] = await child_process.exec('uname -r');
const match = L4T_REGEX.exec(stdout.toString().trim());
if (match == null) {
return;
}
return match[1];
} catch (e) {
log.error('Could not detect l4t version! Error: ', e);
return;
}
}

View File

@ -1,5 +1,7 @@
import { assert, expect } from 'chai';
import { SinonStub, stub } from 'sinon';
import { child_process } from 'mz';
import * as semver from 'semver';
import * as constants from '../src/lib/constants';
@ -11,56 +13,63 @@ import * as osRelease from '../src/lib/os-release';
import supervisorVersion = require('../src/lib/supervisor-version');
describe('Container contracts', () => {
let execStub: SinonStub;
before(() => {
execStub = stub(child_process, 'exec').returns(
Promise.resolve([
Buffer.from('4.9.140-l4t-r32.2+g3dcbed5'),
Buffer.from(''),
]),
);
});
after(() => {
execStub.restore();
});
describe('Contract validation', () => {
it('should correctly validate a contract with no requirements', () => {
assert(
it('should correctly validate a contract with no requirements', () =>
expect(
validateContract({
slug: 'user-container',
}),
);
});
).to.be.fulfilled);
it('should correctly validate a contract with extra fields', () => {
assert(
it('should correctly validate a contract with extra fields', () =>
expect(
validateContract({
slug: 'user-container',
name: 'user-container',
version: '3.0.0',
}),
);
});
).to.be.fulfilled);
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();
return Promise.all([
expect(validateContract({})).to.be.rejected,
expect(validateContract({ name: 'test' })).to.be.rejected,
expect(validateContract({ requires: [] })).to.be.rejected,
]);
});
it('should correctly validate a contract with requirements', () => {
assert(
it('should correctly validate a contract with requirements', () =>
expect(
validateContract({
slug: 'user-container',
requires: [
{
type: 'sw.os',
version: '>3.0.0',
type: 'sw.l4t',
version: '32.2',
},
{
type: 'sw.supervisor',
},
],
}),
);
});
).to.be.fulfilled);
it('should not validate a contract with requirements without the minimum required fields', () => {
expect(() =>
return expect(
validateContract({
slug: 'user-container',
requires: [
@ -69,7 +78,7 @@ describe('Container contracts', () => {
},
],
}),
).to.throw();
).to.be.rejected;
});
});
@ -140,8 +149,8 @@ describe('Container contracts', () => {
slug: 'user-container',
requires: [
{
type: 'sw.os',
version: '>2.0.0',
type: 'sw.supervisor',
version: `>${supervisorVersionLesser}`,
},
],
},
@ -207,8 +216,8 @@ describe('Container contracts', () => {
version: `>${supervisorVersionLesser}`,
},
{
type: 'sw.os',
version: '<3.0.0',
type: 'sw.l4t',
version: '32.2',
},
],
},
@ -263,8 +272,8 @@ describe('Container contracts', () => {
slug: 'user-container',
requires: [
{
type: 'sw.os',
version: '>=3.0.0',
type: 'sw.supervisor',
version: `>=${supervisorVersionGreater}`,
},
],
},
@ -286,12 +295,8 @@ describe('Container contracts', () => {
slug: 'user-container2',
requires: [
{
type: 'sw.supervisor',
version: `>=${supervisorVersionLesser}`,
},
{
type: 'sw.os',
version: '>3.0.0',
type: 'sw.l4t',
version: '28.2',
},
],
},
@ -356,7 +361,7 @@ describe('Container contracts', () => {
slug: 'service1',
requires: [
{
type: 'sw.os',
type: 'sw.supervisor',
version: `<${supervisorVersionGreater}`,
},
],
@ -381,7 +386,7 @@ describe('Container contracts', () => {
slug: 'service1',
requires: [
{
type: 'sw.os',
type: 'sw.supervisor',
version: `>${supervisorVersionGreater}`,
},
],
@ -403,3 +408,38 @@ describe('Container contracts', () => {
});
});
});
describe('L4T version detection', () => {
it('should correctly parse L4T version strings', async () => {
let execStub = stub(child_process, 'exec').returns(
Promise.resolve([
Buffer.from('4.9.140-l4t-r32.2+g3dcbed5'),
Buffer.from(''),
]),
);
expect(await osRelease.getL4tVersion()).to.equal('32.2');
expect(execStub.callCount).to.equal(1);
execStub.restore();
execStub = stub(child_process, 'exec').returns(
Promise.resolve([
Buffer.from('4.4.38-l4t-r28.2+g174510d'),
Buffer.from(''),
]),
);
expect(await osRelease.getL4tVersion()).to.equal('28.2');
expect(execStub.callCount).to.equal(1);
execStub.restore();
});
it('should return undefined when there is no l4t string in uname', async () => {
const execStub = stub(child_process, 'exec').returns(
Promise.resolve([Buffer.from('4.18.14-yocto-standard'), Buffer.from('')]),
);
expect(await osRelease.getL4tVersion()).to.be.undefined;
execStub.restore();
});
});