diff --git a/README.md b/README.md index 6003ca3..de36c03 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,218 @@ # Contrato -> The official contracts implementation +The official [contracts](#about-contracts) implementation -[![Documentation](https://github.com/product-os/contrato/actions/workflows/docs.yml/badge.svg)](https://balena-io.github.io/contrato/modules/contrato.html) +## Quickstart + +```ts +import { Contract } from 'contrato'; + +const osContract = new Contract({ + type: 'sw.os', + slug: 'balenaos', + version: '6.1.2', + children: [ + { type: 'sw.service', slug: 'balena-engine', version: '20.10.43' }, + { type: 'sw.service', slug: 'NetworkManager', version: '0.6.0' }, + ], + provides: [{ type: 'sw.feature', slug: 'secureboot' }], +}); + +const serviceContract = new Contract({ + type: 'sw.application', + slug: 'myapp', + requires: [ + { type: 'sw.service', slug: 'balena-engine', version: '>20' }, + { type: 'sw.feature', slug: 'secureboot' }, + ], +}); + +if (osContract.satisfiesChildContract(serviceContract)) { + console.log('myapp can be installed!'); +} +``` + +[![Documentation](https://github.com/balena-io/contrato/actions/workflows/docs.yml/badge.svg)](https://balena-io.github.io/contrato/modules/contrato.html) + +## About contracts + +### What is a contract? + +Is a specification for describing _things_. A thing can be pretty much anything, a software library, a feature, an API, etc. Relationships between things can be established via composition and referencing (`requires` and `provides`). Through this library, contracts can be validated, composed and combined. + +### Why build this? + +balena.io is a complex product with a great number of inter-conecting components. Each of the components have their own requisites, capabilities, and incompatibilities. Contracts are an effort to formally document those interfaces, and a foundation on which we can build advanced tooling to ultimately automate the process of the team, increase productivity, and remove the human element from tasks that can be performed better by a machine. + +The concept of contracts is generic enough that it can be applied to seemingly unrelated scenarios, from base images and OS images, to device types and backend components. Re-using the same contract "format" between them allows us to multiply the gains we get by developing complex contract-related programming modules. + +### What can I do with contracts? Give me some examples + +Describe a _thing_ via a contract + +```json +{ + "type": "sw.library", + "slug": "glibc", + "version": "2.40", + "assets": { + "license": { + "name": "GNU Lesser General Public License", + "url": "https://www.gnu.org/licenses/lgpl-3.0.html#license-text" + } + } +} +``` + +Describe a _thing_ that requires a _thing_ + +```json +{ + "type": "sw.utility", + "slug": "curl", + "version": "8.11.1", + "requires": [{ "type": "sw.library", "slug": "glibc", "version": ">=2.17" }], + "data": { + "protocols": ["HTTP", "HTTPS", "FTP"] + } +} +``` + +Describe a complex _thing_ via a composite contract + +```json +{ + "type": "sw.os", + "slug": "balenaos", + "version": "4.1.5", + "children": [ + { + "type": "sw.library", + "slug": "glibc", + "version": "2.16", + "assets": { + "license": { + "name": "GNU Lesser General Public License", + "url": "https://www.gnu.org/licenses/lgpl-3.0.html#license-text" + } + } + } + ] +} +``` + +Validate requirements of a contract via [contrato](https://github.com/balena-io/contrato) + +```ts +import { Contract } from 'contrato'; + +const osContract = new Contract({ + type: 'sw.os', + slug: 'balenaos', + version: '4.1.5', + children: [ + { + type: 'sw.library', + slug: 'glibc', + version: '2.16', + }, + ], +}); + +const curlContract = new Contract({ + type: 'sw.utility', + slug: 'curl', + version: '8.11.1', + requires: [{ type: 'sw.library', slug: 'glibc', version: '>=2.17' }], +}); + +if (osContract.satisfiesChildContract(curlContract)) { + console.log('cURL requirements are met and it can be installed!'); +} else { + // cannot install cURL, missing requirements: { type: 'sw.library', slug: 'glibc', version: '>=2.17' } + console.log( + 'cannot install cURL, missing requirements: ', + osContract.getNotSatisfiedChildRequirements(curlContract), + ); +} +``` + +Describe a universe of _things_ + +```ts +import { Contract, Universe } from 'contrato'; + +const universe = new Universe(); +universe.addChildren([ + new Contract({ type: 'sw.os', slug: 'debian' }), + new Contract({ type: 'sw.os', slug: 'fedora' }), + new Contract({ + type: 'arch.sw', + slug: 'armv7hf', + requires: [{ type: 'hw.device-type', data: { arch: 'armv7hf' } }], + }), + new Contract({ + type: 'arch.sw', + slug: 'amd64', + requires: [{ type: 'hw.device-type', data: { arch: 'amd64' } }], + }), + new Contract({ + type: 'hw.device-type', + slug: 'raspberrypi3', + data: { arch: 'armv7hf' /* ... */ }, + }), + new Contract({ + type: 'hw.device-type', + slug: 'intel-nuc', + data: { arch: 'amd64' /* ... */ }, + }), +]); +``` + +Generate combinations of _things_ with a Blueprint + +```ts +import { Contract, Universe, Blueprint } from 'contrato'; + +const universe = new Universe(); +universe.addChildren([ + /* ... */ +]); + +const blueprint = new Blueprint( + { 'hw.device-type': 1, 'arch.sw': 1, 'sw.os': 1 }, + { type: 'meta.context' }, +); + +// Generate contexts with valid combinations of the given types +const contexts = blueprint.reproduce(universe); +``` + +Build templates using the metadata from a combination + +````ts +import { Contract, Universe, Blueprint, buildTemplate } from 'contrato'; + +/* ... */ + +// Generate contexts with valid combinations of the given types +const contexts = blueprint.reproduce(universe); +const template = ``` +Welcome to {{this.sw.os.slug}}OS for {{this.hw.device-type.slug}}! + +This build supports the architecture {{this.arch.sw.slug}} +```; + +for (const context of contexts) { + // Welcome to OS fedoraOS for intel-nuc + // ... + console.log(buildTemplate(template, context)); +} +```` + +### Additional information + +See [contracts specification](balena-contracts.md) for additional documentation on the contract format. ## Tests diff --git a/balena-contracts.cue b/balena-contracts.cue new file mode 100644 index 0000000..08a2d64 --- /dev/null +++ b/balena-contracts.cue @@ -0,0 +1,262 @@ +info: { + title: "Contracts Specification" + version: "1.0.0" + license: { + name: "Apache 2.0" + url: "https://www.apache.org/licenses/LICENSE-2.0.html" + } +} + +// Semantic Versioning compliant version (see https://semver.org) +// +// Example: `3.1.5` +#SemVer: string + +// Semantic Version range +// +// See https://github.com/npm/node-semver#ranges +// +// Example: `>=1.0.0` +#SemVerRange: string + +// A namespaced type string +#Type: =~"^[a-zA-Z][a-zA-Z0-9.-]*$" + +// A slug string +#Slug: =~"^[a-zA-Z][a-zA-Z0-9-.]*$" + +// Uniform resource location +#URL: "^(http|https|file)://.+$" + +#Asset: { + name?: string + url: #URL + checksum?: string + checksumType?: string + + // Validation: If checksum is present, checksumType must also be present + // (this doesn't translate to openapi) + if checksum != _|_ { + checksumType: string + } +} + +// A matcher to a contract or a range of contracts +// +// Examples: +// Match all hw.device-type contracts with the given data +// ```yml +// type: hw.device-type +// data: +// arch: armv7hf +// hdmi: true +// ``` +// +// Match all alpine versions bigger than 3.15 +// ```yml +// type: sw.os +// slug: alpine +// version: >=3.15 +// ``` +#ContractMatcher: { + #Contract + version?: #SemVerRange +} + +// A contract requirement +#ContractRequirement: #ContractMatcher | {or: [...#ContractRequirement]} | {not: [...#ContractRequirement]} + +// The contract metadata specification +#ContractMetadata: { + // A semver version of the entity definition as we have defined it in the contract. The version should be updated every time the contract information changes. + // + // If not provided, we assume the contract is version 1.0.0 + // + // Example: `1.0.1` + version?: #SemVer + // A human readable name of the entity. + // + // Example: `Raspberry PI 3` + name?: string + // A human readable description of the entity + // + // Example: `Single-board device to enable your IoT projects` + description?: string + // Alternative, globally unique slugs + // + // Example: `[ 'rpi3', 'raspberry-pi3' ]` + aliases?: [...#Slug] + // A free-form object for contract specific information. Notice that contracts are not allowed to define any extra top-level properties, so any information specific to a type must live inside data + data: {...} + // The assets this contract requires. + // There are two types of assets: + // - Local (declared with a file path) + // - Remote (declared with a URL) + // + // If the protocol prefix is not provided, `file://` is assumed. Slashes should be used as path separators (UNIX style). + // The url data property is mandatory. + // If name is not provided, the asset key can be used as a substitute. + // The checksum property is optional, but if present, checksumType must exist. + // + // Example: + // ```yml + // assets: + // bin: + // name: qemu-arm-static + // url: file://./assets/qemu-arm-static + // checksum: 7bce65c956bbddbf83a8ce9121b505657e835df4a064823de51623858c25090f + // checksumType: sha256 + // ``` + assets: { + [string]: #Asset + } + // Enables each contract to specify its requirements on the environment in order to be valid. + // The requirements are specified as a contract reference or an operation (`or`,`not`) on requirements + // + // Example: + // ```yml + // type: sw.application + // slug: balena-sound + // requires: + // - or: + // - type: hw.connector + // slug: hdmiv1.5 + // - type: hw.connector + // slug: usb3 + // ``` + requires?: [...#ContractRequirement] + // Allows to specify what functionalities + // or capabilities from the environment an entity defined by the contract provides. + // + // Differently from requirements, only a list of contract references is supported for now + // + // Example: + // ```yml + // type: sw.application + // slug: balena-os-for-raspberrypi3 + // provides: + // - type: sw.os + // slug: balenaos + // ``` + provides?: [...#ContractMatcher] + // Allows to specify contract alternatives for different sets of requirements. + // + // It can be combined with templating to generate a large number of contracts + // from a short specification + // For an example, see: https://github.com/balena-io/contracts/blob/master/contracts/sw.stack/node/contract.json + variants?: [...#ContractMetadata] + // A contract can contain other contracts, which makes it a composite contract. + // This is accomplished by adding other contracts inside the `children` property + children?: [...#Contract] +} + +// A contract is a specification for describing _things_. A thing can be pretty much anything, +// a software library, a feature, an API, etc. Relationships between things can be established +// via composition and referencing (`requires` and `provides`). +// +// Example: +// ```json +// { +// "slug": "raspberrypi3", +// "version": "1", +// "type": "hw.device-type", +// "aliases": [], +// "name": "Raspberry Pi 3", +// "assets": { +// "logo": { +// "url": "./raspberrypi3.svg", +// "name": "logo" +// } +// }, +// "data": { +// "arch": "armv7hf", +// "hdmi": true, +// "led": true, +// "connectivity": { +// "bluetooth": true, +// "wifi": true +// }, +// "storage": { +// "internal": false +// }, +// "media": { +// "defaultBoot": "sdcard", +// "altBoot": ["usb_mass_storage", "network"] +// }, +// "is_private": false +// } +// } +// ``` +#Contract: { + // The type of a contract, which mostly aims to determine the contents of the free-form data object. + // Ideally types should be namespaced to avoid clashing of contract types. + // + // Example: `hw.device-type` + type: #Type + // Unique identifier for the contract. No two contracts of the same type should have the same slug. + // + // Example: `raspberrypi3` + slug?: #Slug + + // The contract body + #ContractMetadata +} + +// A cardinality operator +// +// A cardinality is usually represented with a tuple that defines a range of +// integers. On top of that, the following syntax sugar is supported. +// Assuming `x` in an integer: +// - `x` -> `[ x, x ]` +// - `*` -> `[ 0, Infinity ]` +// - `?` -> `[ 0, 1 ]` +// - `1?` -> `[ 0, 1 ]` +// - `'x'` -> `[ x, x ]` +// - `x+` -> `[ x, Infinity ]` +// - `[ x, '*' ]` -> `[ x, Infinity ]` +#Cardinality: string + +// A JSON schema filter +// +// Example +// ```json +// { +// "type": "object", +// "properties": { +// "slug": { +// "const": "armv7hf" +// } +// } +// } +// ``` +#FilterSchema: {...} + +// A set of cardinality operators for a blueprint +#BlueprintLayout: { + [string]: #Cardinality | {cardinality: #Cardinality, filter: #FilterSchema} +} + +// A blueprint is a partial contract that defines how to generate a certain combination of contracts +// from a universe. The result of "applying" a blueprint on a universe is a set of contexts. +#Blueprint: { + type: "meta.blueprint" + // The query for the blueprint using a set of cardinality operators + // + // Example + // ```yml + // selector: + // sw.os: '1' + // sw.blob: '*', + // arch.sw: + // cardinality: [0,1] + // filter: + // type: object + // properties: + // slug: + // const: armv7hf + // ``` + layout: #BlueprintLayout + // An object describing how the resulting contexts should look like. You may use properties such as type, slug, data, etc. + // You may use blueprint results to construct certain properties by accessing children through the children property. + skeleton?: {...} +} diff --git a/balena-contracts.md b/balena-contracts.md new file mode 100644 index 0000000..0ba7c75 --- /dev/null +++ b/balena-contracts.md @@ -0,0 +1,214 @@ +# Contracts Specification +## Version: 1.0.0 + +**License:** [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) + +### Models + + +#### Asset + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | No | +| url | [URL](#url) | | Yes | +| checksum | string | | No | +| checksumType | string | | No | + +#### Blueprint + +A blueprint is a partial contract that defines how to generate a certain combination of contracts +from a universe. The result of "applying" a blueprint on a universe is a set of contexts. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string | | Yes | +| layout | [BlueprintLayout](#blueprintlayout) | | Yes | +| skeleton | object | An object describing how the resulting contexts should look like. You may use properties such as type, slug, data, etc. You may use blueprint results to construct certain properties by accessing children through the children property. | No | + +#### BlueprintLayout + +A set of cardinality operators for a blueprint + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| BlueprintLayout | object | A set of cardinality operators for a blueprint | | + +#### Cardinality + +A cardinality operator + +A cardinality is usually represented with a tuple that defines a range of +integers. On top of that, the following syntax sugar is supported. +Assuming `x` in an integer: +- `x` -> `[ x, x ]` +- `*` -> `[ 0, Infinity ]` +- `?` -> `[ 0, 1 ]` +- `1?` -> `[ 0, 1 ]` +- `'x'` -> `[ x, x ]` +- `x+` -> `[ x, Infinity ]` +- `[ x, '*' ]` -> `[ x, Infinity ]` + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| Cardinality | string | A cardinality operator A cardinality is usually represented with a tuple that defines a range of integers. On top of that, the following syntax sugar is supported. Assuming `x` in an integer: - `x` -> `[ x, x ]` - `*` -> `[ 0, Infinity ]` - `?` -> `[ 0, 1 ]` - `1?` -> `[ 0, 1 ]` - `'x'` -> `[ x, x ]` - `x+` -> `[ x, Infinity ]` - `[ x, '*' ]` -> `[ x, Infinity ]` | | + +#### Contract + +A contract is a specification for describing _things_. A thing can be pretty much anything, +a software library, a feature, an API, etc. Relationships between things can be established +via composition and referencing (`requires` and `provides`). + +Example: +```json +{ + "slug": "raspberrypi3", + "version": "1", + "type": "hw.device-type", + "aliases": [], + "name": "Raspberry Pi 3", + "assets": { + "logo": { + "url": "./raspberrypi3.svg", + "name": "logo" + } + }, + "data": { + "arch": "armv7hf", + "hdmi": true, + "led": true, + "connectivity": { + "bluetooth": true, + "wifi": true + }, + "storage": { + "internal": false + }, + "media": { + "defaultBoot": "sdcard", + "altBoot": ["usb_mass_storage", "network"] + }, + "is_private": false + } +} +``` + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | [Type](#type) | | No | +| slug | [Slug](#slug) | | No | + +#### ContractMatcher + +A matcher to a contract or a range of contracts + +Examples: +Match all hw.device-type contracts with the given data +```yml +type: hw.device-type +data: + arch: armv7hf + hdmi: true +``` + +Match all alpine versions bigger than 3.15 +```yml +type: sw.os +slug: alpine +version: >=3.15 +``` + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| version | [SemVerRange](#semverrange) | | No | + +#### ContractMetadata + +The contract metadata specification + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| version | [SemVer](#semver) | | No | +| name | string | A human readable name of the entity. Example: `Raspberry PI 3` | No | +| description | string | A human readable description of the entity Example: `Single-board device to enable your IoT projects` | No | +| aliases | [ [Slug](#slug) ] | Alternative, globally unique slugs Example: `[ 'rpi3', 'raspberry-pi3' ]` | No | +| data | object | A free-form object for contract specific information. Notice that contracts are not allowed to define any extra top-level properties, so any information specific to a type must live inside data | Yes | +| assets | object | The assets this contract requires. There are two types of assets: - Local (declared with a file path) - Remote (declared with a URL) If the protocol prefix is not provided, `file://` is assumed. Slashes should be used as path separators (UNIX style). The url data property is mandatory. If name is not provided, the asset key can be used as a substitute. The checksum property is optional, but if present, checksumType must exist. Example: ```yml assets: bin: name: qemu-arm-static url: file://./assets/qemu-arm-static checksum: 7bce65c956bbddbf83a8ce9121b505657e835df4a064823de51623858c25090f checksumType: sha256 ``` | Yes | +| requires | [ [ContractRequirement](#contractrequirement) ] | Enables each contract to specify its requirements on the environment in order to be valid. The requirements are specified as a contract reference or an operation (`or`,`not`) on requirements Example: ```yml type: sw.application slug: balena-sound requires: - or: - type: hw.connector slug: hdmiv1.5 - type: hw.connector slug: usb3 ``` | No | +| provides | [ [ContractMatcher](#contractmatcher) ] | Allows to specify what functionalities or capabilities from the environment an entity defined by the contract provides. Differently from requirements, only a list of contract references is supported for now Example: ```yml type: sw.application slug: balena-os-for-raspberrypi3 provides: - type: sw.os slug: balenaos ``` | No | +| variants | [ [ContractMetadata](#contractmetadata) ] | Allows to specify contract alternatives for different sets of requirements. It can be combined with templating to generate a large number of contracts from a short specification For an example, see: https://github.com/balena-io/contracts/blob/master/contracts/sw.stack/node/contract.json | No | +| children | [ [Contract](#contract) ] | A contract can contain other contracts, which makes it a composite contract. This is accomplished by adding other contracts inside the `children` property | No | + +#### ContractRequirement + +A contract requirement + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ContractRequirement | object | A contract requirement | | + +#### FilterSchema + +A JSON schema filter + +Example +```json +{ + "type": "object", + "properties": { + "slug": { + "const": "armv7hf" + } + } +} +``` + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FilterSchema | object | A JSON schema filter Example ```json { "type": "object", "properties": { "slug": { "const": "armv7hf" } } } ``` | | + +#### SemVer + +Semantic Versioning compliant version (see https://semver.org) + +Example: `3.1.5` + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SemVer | string | Semantic Versioning compliant version (see https://semver.org) Example: `3.1.5` | | + +#### SemVerRange + +Semantic Version range + +See https://github.com/npm/node-semver#ranges + +Example: `>=1.0.0` + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SemVerRange | string | Semantic Version range See https://github.com/npm/node-semver#ranges Example: `>=1.0.0` | | + +#### Slug + +A slug string + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| Slug | string | A slug string | | + +#### Type + +A namespaced type string + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| Type | string | A namespaced type string | | + +#### URL + +Uniform resource location + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| URL | string | Uniform resource location | | \ No newline at end of file