Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions lib/multibuild/balena-contract-features.ts
Original file line number Diff line number Diff line change
@@ -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: [],
};
}
3 changes: 2 additions & 1 deletion lib/multibuild/build-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -137,7 +138,7 @@ export interface BuildTask {
/**
* The container contract for this service
*/
contract?: Dictionary<unknown>;
contract?: Contract;

/**
* Promise to ensure that build task is resolved before
Expand Down
7 changes: 4 additions & 3 deletions lib/multibuild/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,14 +29,14 @@ export function isContractFile(filename: string): boolean {
return normalized === 'contract.yml' || normalized === 'contract.yaml';
}

export function processContract(buffer: Buffer): Dictionary<unknown> {
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<unknown>;
const contractObj = parsedBuffer as Dictionary<any>;

if (contractObj.name == null) {
throw new ContractValidationError(
Expand All @@ -59,7 +60,7 @@ export function processContract(buffer: Buffer): Dictionary<unknown> {
);
}

return contractObj;
return contractObj as Contract;
}

export function checkContractNamesUnique(tasks: BuildTask[]) {
Expand Down
8 changes: 8 additions & 0 deletions lib/multibuild/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 13 additions & 5 deletions lib/parse/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
}
Expand Down Expand Up @@ -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) {
Expand All @@ -556,7 +564,7 @@ function createImageDescriptor(
build.tag = service.image;
}

return { serviceName, image: build };
return { serviceName, image: build, originalComposition };
}

function normalizeKeyValuePairs(
Expand Down
1 change: 1 addition & 0 deletions lib/parse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,5 @@ export interface BuildConfig {
export interface ImageDescriptor {
serviceName: string;
image: string | BuildConfig;
originalComposition?: Composition;
}
23 changes: 0 additions & 23 deletions test/parse/all.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down