diff --git a/lib/multibuild/balena-contract-features.ts b/lib/multibuild/balena-contract-features.ts new file mode 100644 index 0000000..3f91a02 --- /dev/null +++ b/lib/multibuild/balena-contract-features.ts @@ -0,0 +1,76 @@ +import type { BuildTask } from './build-task'; +import type * as Compose from '../parse'; + +export interface ContractFeature { + type: string; + version?: string; +} + +export interface Contract { + name: string; + type: 'sw.container'; + slug: string; + requires?: ContractFeature[]; +} + +export function insertBalenaCustomContractFeatures( + task: BuildTask, + image: Compose.ImageDescriptor, +): void { + insertDependsOnServiceHeathyFeature(task, image); +} + +function insertDependsOnServiceHeathyFeature( + task: BuildTask, + image: Compose.ImageDescriptor, +): void { + const serviceNames = Object.keys(image.originalComposition?.services ?? {}); + for (const serviceName of serviceNames) { + const service = image.originalComposition?.services[serviceName]; + if (service?.depends_on != null) { + for (const dep of service.depends_on) { + if (dep === 'service_healthy' || dep === 'service-healthy') { + const feature: ContractFeature = { + type: 'sw.private.compose.service-healthy-depends-on', + version: '1.0.0', + }; + + insertContractFeature(task, feature); + return; + } + } + } + } +} + +function insertContractFeature( + task: BuildTask, + feature: ContractFeature, +): void { + if (task.contract == null) { + task.contract = defaultContract(); + } + + if ( + task.contract.requires + ?.map((require) => require.type) + .includes(feature.type) + ) { + return; + } + + if (task.contract.requires != null) { + task.contract.requires.push(feature); + } else { + task.contract.requires = [feature]; + } +} + +function defaultContract(): Contract { + return { + name: 'default', + type: 'sw.container', + slug: 'default', + requires: [], + }; +} diff --git a/lib/multibuild/build-task.ts b/lib/multibuild/build-task.ts index 737acb0..d78c667 100644 --- a/lib/multibuild/build-task.ts +++ b/lib/multibuild/build-task.ts @@ -18,6 +18,7 @@ import type { ProgressCallback } from 'docker-progress'; import type * as Stream from 'stream'; import type * as tar from 'tar-stream'; import type BuildMetadata from './build-metadata'; +import type { Contract } from './balena-contract-features'; /** * A structure representing a list of build tasks to be performed, @@ -137,7 +138,7 @@ export interface BuildTask { /** * The container contract for this service */ - contract?: Dictionary; + contract?: Contract; /** * Promise to ensure that build task is resolved before diff --git a/lib/multibuild/contracts.ts b/lib/multibuild/contracts.ts index d35cb73..3e8afcf 100644 --- a/lib/multibuild/contracts.ts +++ b/lib/multibuild/contracts.ts @@ -20,6 +20,7 @@ import * as TarUtils from 'tar-utils'; import type { BuildTask } from './build-task'; import { ContractValidationError, NonUniqueContractNameError } from './errors'; +import type { Contract } from './balena-contract-features'; export const CONTRACT_TYPE = 'sw.container'; @@ -28,14 +29,14 @@ export function isContractFile(filename: string): boolean { return normalized === 'contract.yml' || normalized === 'contract.yaml'; } -export function processContract(buffer: Buffer): Dictionary { +export function processContract(buffer: Buffer): Contract { const parsedBuffer = jsYaml.load(buffer.toString('utf8')); if (parsedBuffer == null || typeof parsedBuffer !== 'object') { throw new ContractValidationError('Container contract must be an object'); } - const contractObj = parsedBuffer as Dictionary; + const contractObj = parsedBuffer as Dictionary; if (contractObj.name == null) { throw new ContractValidationError( @@ -59,7 +60,7 @@ export function processContract(buffer: Buffer): Dictionary { ); } - return contractObj; + return contractObj as Contract; } export function checkContractNamesUnique(tasks: BuildTask[]) { diff --git a/lib/multibuild/index.ts b/lib/multibuild/index.ts index 064fcb5..c8f297c 100644 --- a/lib/multibuild/index.ts +++ b/lib/multibuild/index.ts @@ -49,6 +49,7 @@ import { posixContains } from './path-utils'; import type { RegistrySecrets } from './registry-secrets'; import { ResolveListeners, resolveTask } from './resolve'; import * as Utils from './utils'; +import { insertBalenaCustomContractFeatures } from './balena-contract-features'; export { QEMU_BIN_NAME } from './build-metadata'; export * from './build-task'; @@ -124,6 +125,13 @@ export async function fromImageDescriptors( task.contract = contracts.processContract(buf); } + const image = images.find( + (i) => i.serviceName === task.serviceName, + ); + if (image != null) { + insertBalenaCustomContractFeatures(task, image); + } + const newHeader = _.cloneDeep(header); newHeader.name = relative; task.buildStream!.entry(newHeader, buf); diff --git a/lib/parse/compose.ts b/lib/parse/compose.ts index 7b639c7..df4043f 100644 --- a/lib/parse/compose.ts +++ b/lib/parse/compose.ts @@ -237,11 +237,18 @@ function normalizeService( if (!Array.isArray(service.depends_on)) { // Try to convert long-form into list-of-strings service.depends_on = _.map(service.depends_on, (dep, serviceName) => { - if (['service_started', 'service-started'].includes(dep.condition)) { + if ( + [ + 'service_started', + 'service-started', + 'service_healthy', + 'service-healthy', + ].includes(dep.condition) + ) { return serviceName; } throw new ValidationError( - 'Only "service_started" type of service dependency is supported', + 'Only "service_started" and "service_healthy" type of service dependency are supported', ); }); } @@ -532,16 +539,17 @@ export function parse(c: Composition): ImageDescriptor[] { throw new Error('Unsupported composition version'); } return _.toPairs(c.services).map(([name, service]) => { - return createImageDescriptor(name, service); + return createImageDescriptor(name, service, c); }); } function createImageDescriptor( serviceName: string, service: Service, + originalComposition?: Composition, ): ImageDescriptor { if (service.image && !service.build) { - return { serviceName, image: service.image }; + return { serviceName, image: service.image, originalComposition }; } if (!service.build) { @@ -556,7 +564,7 @@ function createImageDescriptor( build.tag = service.image; } - return { serviceName, image: build }; + return { serviceName, image: build, originalComposition }; } function normalizeKeyValuePairs( diff --git a/lib/parse/types.ts b/lib/parse/types.ts index 5924635..b0cb960 100644 --- a/lib/parse/types.ts +++ b/lib/parse/types.ts @@ -218,4 +218,5 @@ export interface BuildConfig { export interface ImageDescriptor { serviceName: string; image: string | BuildConfig; + originalComposition?: Composition; } diff --git a/test/parse/all.spec.ts b/test/parse/all.spec.ts index 25d246f..696da2c 100644 --- a/test/parse/all.spec.ts +++ b/test/parse/all.spec.ts @@ -389,29 +389,6 @@ describe('validation', () => { expect(f).to.not.throw(); }); - it('should throw when long syntax depends_on does not specify service_started condition', async () => { - const f = () => { - compose.normalize({ - version: '2.4', - services: { - main: { - build: '.', - depends_on: { - dependency: { condition: 'service_healthy' }, - }, - }, - dependency: { - build: '.', - }, - }, - }); - }; - expect(f).to.throw( - ValidationError, - 'Only "service_started" type of service dependency is supported', - ); - }); - it('should throw when long syntax tmpfs mounts specify options', async () => { const f = () => { compose.normalize({