mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-31 08:25:36 +00:00
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:
parent
bba6d3d728
commit
1a6c9d489a
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user