Skip to content

Commit

Permalink
Add contract partial based instruction generation
Browse files Browse the repository at this point in the history
Change-type: minor
Signed-off-by: Micah Halter <micah@balena.io>
  • Loading branch information
mehalter committed Oct 27, 2022
1 parent 8f02a5f commit 1bb297e
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 3 deletions.
76 changes: 76 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@ const sdk = fromSharedOptions();
* [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ <code>Promise</code>
* [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ <code>Promise</code>
* [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ <code>Promise</code>
* [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ <code>Promise</code>
* [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ <code>Promise</code>
* [.getInstallMethod(deviceTypeSlug)](#balena.models.deviceType.getInstallMethod) ⇒ <code>Promise</code>
* [.apiKey](#balena.models.apiKey) : <code>object</code>
* [.create(name, [description])](#balena.models.apiKey.create) ⇒ <code>Promise</code>
* [.getAll([options])](#balena.models.apiKey.getAll) ⇒ <code>Promise</code>
Expand Down Expand Up @@ -691,6 +694,9 @@ balena.models.device.get(123).catch(function (error) {
* [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ <code>Promise</code>
* [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ <code>Promise</code>
* [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ <code>Promise</code>
* [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ <code>Promise</code>
* [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ <code>Promise</code>
* [.getInstallMethod(deviceTypeSlug)](#balena.models.deviceType.getInstallMethod) ⇒ <code>Promise</code>
* [.apiKey](#balena.models.apiKey) : <code>object</code>
* [.create(name, [description])](#balena.models.apiKey.create) ⇒ <code>Promise</code>
* [.getAll([options])](#balena.models.apiKey.getAll) ⇒ <code>Promise</code>
Expand Down Expand Up @@ -5196,6 +5202,9 @@ balena.models.device.restartService('7cf02a6', 123, function(error) {
* [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ <code>Promise</code>
* [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ <code>Promise</code>
* [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ <code>Promise</code>
* [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ <code>Promise</code>
* [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ <code>Promise</code>
* [.getInstallMethod(deviceTypeSlug)](#balena.models.deviceType.getInstallMethod) ⇒ <code>Promise</code>

<a name="balena.models.deviceType.get"></a>

Expand Down Expand Up @@ -5376,6 +5385,73 @@ balena.models.deviceType.getSlugByName('Raspberry Pi', function(error, deviceTyp
// raspberry-pi
});
```
<a name="balena.models.deviceType.getInterpolatedPartials"></a>

##### deviceType.getInterpolatedPartials(deviceTypeSlug, initial) ⇒ <code>Promise</code>
**Kind**: static method of [<code>deviceType</code>](#balena.models.deviceType)
**Summary**: Get a contract with resolved partial templates
**Access**: public
**Fulfil**: <code>Contract</code> - device type contract with resolved partials

| Param | Type | Description |
| --- | --- | --- |
| deviceTypeSlug | <code>String</code> | device type slug |
| initial | <code>any</code> | Other contract values necessary for interpreting contracts |

**Example**
```js
balena.models.deviceType.getInterpolatedPartials('raspberry-pi').then(function(contract) {
for (const partial in contract.partials) {
console.log(`${partial}: ${contract.partials[partial]}`);
}
// bootDevice: ["Connect power to the Raspberry Pi (v1 / Zero / Zero W)"]
});
```
<a name="balena.models.deviceType.getInstructions"></a>

##### deviceType.getInstructions(deviceTypeSlug) ⇒ <code>Promise</code>
**Kind**: static method of [<code>deviceType</code>](#balena.models.deviceType)
**Summary**: Get instructions for installing a host OS on a given device type
**Access**: public
**Fulfil**: <code>String[]</code> - step by step instructions for installing the host OS to the device

| Param | Type | Description |
| --- | --- | --- |
| deviceTypeSlug | <code>String</code> | device type slug |

**Example**
```js
balena.models.deviceType.getInstructions('raspberry-pi').then(function(instructions) {
for (let instruction of instructions.values()) {
console.log(instruction);
}
// Insert the sdcard to the host machine.
// Write the BalenaOS file you downloaded to the sdcard. We recommend using <a href="http://www.etcher.io/">Etcher</a>.
// Wait for writing of BalenaOS to complete.
// Remove the sdcard from the host machine.
// Insert the freshly flashed sdcard into the Raspberry Pi (v1 / Zero / Zero W).
// Connect power to the Raspberry Pi (v1 / Zero / Zero W) to boot the device.
});
```
<a name="balena.models.deviceType.getInstallMethod"></a>

##### deviceType.getInstallMethod(deviceTypeSlug) ⇒ <code>Promise</code>
**Kind**: static method of [<code>deviceType</code>](#balena.models.deviceType)
**Summary**: Get installation method on a given device type
**Access**: public
**Fulfil**: <code>String</code> - the installation method supported for the given device type slug

| Param | Type | Description |
| --- | --- | --- |
| deviceTypeSlug | <code>String</code> | device type slug |

**Example**
```js
balena.models.deviceType.getInstallMethod('raspberry-pi').then(function(method) {
console.log(method);
// externalBoot
});
```
<a name="balena.models.apiKey"></a>

#### models.apiKey : <code>object</code>
Expand Down
76 changes: 76 additions & 0 deletions lib/models/balenaos-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Contract } from '../types/contract';

// Hardcoded host OS contract, this should be moved to the Yocto build process with meta-balena.
// Here for initial implementatin and testing purposes
const BalenaOS: Contract = {
name: 'balenaOS',
slug: 'balenaos',
type: 'sw.os',
description: 'Balena OS',
partials: {
image: [`{{#each deviceType.partials.instructions}}{{{this}}} {{/each}}`],
internalFlash: [
`{{#each deviceType.partials.connectDevice}}{{{this}}} {{/each}}`,
`Write the {{name}} file you downloaded to the {{deviceType.name}}. We recommend using <a href="http://www.etcher.io/">Etcher</a>.`,
`Wait for writing of {{name}} to complete.`,
`{{#each deviceType.partials.disconnectDevice}}{{{this}}} {{/each}}`,
`{{{deviceType.partials.bootDevice}}} to boot the device.`,
],
externalFlash: [
`Insert the {{deviceType.data.media.altBoot.[0]}} to the host machine.`,
`Write the {{name}} file you downloaded to the {{deviceType.data.media.altBoot.[0]}}. We recommend using <a href="http://www.etcher.io/">Etcher</a>.`,
`Wait for writing of {{name}} to complete.`,
`Remove the {{deviceType.data.media.altBoot.[0]}} from the host machine.`,
`Insert the freshly flashed {{deviceType.data.media.altBoot.[0]}} into the {{deviceType.name}}.`,
`<strong role="alert">Warning!</strong> This will also completely erase internal storage medium, so please make a backup first.`,
`{{#each deviceType.partials.bootDeviceExternal}}{{{this}}} {{/each}}`,
`Wait for the {{deviceType.name}} to finish flashing and shutdown. {{#if deviceType.partials.flashIndicator}}Please wait until {{deviceType.partials.flashIndicator}}.{{/if}}`,
`Remove the {{deviceType.data.media.altBoot.[0]}} from the {{deviceType.name}}.`,
`{{#each deviceType.partials.bootDeviceInternal}}{{{this}}} {{/each}}`,
`{{{deviceType.partials.bootDevice}}} to boot the device.`,
],
externalBoot: [
`Insert the {{deviceType.data.media.defaultBoot}} to the host machine.`,
`Write the {{name}} file you downloaded to the {{deviceType.data.media.defaultBoot}}. We recommend using <a href="http://www.etcher.io/">Etcher</a>.`,
`Wait for writing of {{name}} to complete.`,
`Remove the {{deviceType.data.media.defaultBoot}} from the host machine.`,
`Insert the freshly flashed {{deviceType.data.media.defaultBoot}} into the {{deviceType.name}}.`,
`{{{deviceType.partials.bootDevice}}} to boot the device.`,
],
jetsonFlash: [
`Put the device in recovery mode and connect to the host computer via USB`,
`{{#if deviceType.partials.jetsonNotes}}{{#each deviceType.partials.jetsonNotes}}{{{this}}} {{/each}}{{/if}}`,
`Unzip the {{name}} image and use the Jetson Flash tool to flash the {{deviceType.name}} found at <a href="https://github.com/balena-os/jetson-flash">https://github.com/balena-os/jetson-flash</a>.`,
`Wait for writing of {{name}} to complete.`,
`{{{deviceType.partials.bootDevice}}} to boot the device.`,
],
edisonFlash: {
Linux: [
`{{#each deviceType.partials.Linux.flashDependencies}}{{{this}}} {{/each}}`,
`Unplug the {{deviceType.name}} from your system`,
`Unzip the downloaded {{name}} file`,
`{{#each deviceType.partials.Linux.flashInstructions}}{{{this}}} {{/each}}`,
`Plug the {{deviceType.name}} as per the instructions on your terminal.`,
`You can check the progress of the provisioning on your terminal.`,
],
MacOS: [
`{{#each deviceType.partials.MacOS.flashDependencies}}{{{this}}} {{/each}}`,
`Unplug the {{deviceType.name}} from your system`,
`Unzip the downloaded {{name}} file`,
`{{#each deviceType.partials.MacOS.flashInstructions}}{{{this}}} {{/each}}`,
`Plug the {{deviceType.name}} as per the instructions on your terminal.`,
`You can check the progress of the provisioning on your terminal.`,
],
Windows: [
`{{#each deviceType.partials.Windows.flashDependencies}}{{{this}}} {{/each}}`,
`Unplug the {{deviceType.name}} from your system`,
`Unzip the downloaded {{name}} file`,
`{{#each deviceType.partials.Windows.flashInstructions}}{{{this}}} {{/each}}`,
`Plug the {{deviceType.name}} as per the instructions on your terminal.`,
`You can check the progress of the provisioning on your terminal.`,
],
},
},
};

export { BalenaOS };
173 changes: 173 additions & 0 deletions lib/models/device-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,74 @@ limitations under the License.

import type { InjectedDependenciesParam, PineOptions } from '..';
import { DeviceType } from '../types/models';
import { Contract } from '../types/contract';
import { mergePineOptions } from '../util';
import * as errors from 'balena-errors';
import * as Handlebars from 'handlebars';
import cloneDeep = require('lodash/cloneDeep');

// REPLACE ONCE HOST OS CONTRACTS ARE GENERATED THROUGH YOCTO
import { BalenaOS } from './balenaos-contract';

const traversingCompile = (partials: any, initial: any, keys: string[]) => {
return Object.keys(partials).reduce(
(interpolated: any, partialKey) => {
const current = partials[partialKey];
if (Array.isArray(current)) {
let location = interpolated;
for (const key of keys) {
location = location[key];
}
// if array of partials, compile the template
location[partialKey] = current
.map((partial: string) => Handlebars.compile(partial)(interpolated))
.filter((n) => n);
} else {
// if it's another dictionary, keep traversing
interpolated = traversingCompile(
current,
interpolated,
keys.concat([partialKey]),
);
}
return interpolated;
},
{ ...initial },
);
};

const interpolatedPartials = (contract: Contract, initial: any = {}) => {
const fullInitial = { ...contract, ...initial };
if (contract.partials) {
return traversingCompile(contract.partials, fullInitial, ['partials']);
} else {
return fullInitial;
}
};

const calculateInstallMethod = (contract: Contract): string => {
const flashProtocol = contract.data?.flashProtocol;
const defaultBoot = contract.data?.media?.defaultBoot;
if (flashProtocol) {
if (flashProtocol === 'RPIBOOT') {
return 'internalFlash';
} else {
return flashProtocol;
}
} else if (defaultBoot) {
if (defaultBoot === 'image') {
return 'image';
} else if (defaultBoot === 'internal') {
return 'externalFlash';
} else {
return 'externalBoot';
}
} else {
throw new errors.BalenaError(
`Unable to determine installation method for contract: ${contract.slug}`,
);
}
};

const getDeviceTypeModel = function (deps: InjectedDependenciesParam) {
const { pine } = deps;
Expand Down Expand Up @@ -323,6 +389,113 @@ const getDeviceTypeModel = function (deps: InjectedDependenciesParam) {
await exports.getBySlugOrName(deviceTypeName, { $select: 'slug' })
).slug;
},

/**
* @summary Get a contract with resolved partial templates
* @name getInterpolatedPartials
* @public
* @function
* @memberof balena.models.deviceType
*
* @param {String} deviceTypeSlug - device type slug
* @param {any} initial - Other contract values necessary for interpreting contracts
* @fulfil {Contract} - device type contract with resolved partials
* @returns {Promise}
*
* @example
* balena.models.deviceType.getInterpolatedPartials('raspberry-pi').then(function(contract) {
* for (const partial in contract.partials) {
* console.log(`${partial}: ${contract.partials[partial]}`);
* }
* // bootDevice: ["Connect power to the Raspberry Pi (v1 / Zero / Zero W)"]
* });
*/
getInterpolatedPartials: async (
deviceTypeSlug: string,
initial: any = {},
): Promise<Contract> => {
const contract = (
await exports.getBySlugOrName(deviceTypeSlug, { $select: 'contract' })
).contract;
if (!contract) {
throw new Error('Slug does not contain contract');
}
return interpolatedPartials(contract, initial);
},

/**
* @summary Get instructions for installing a host OS on a given device type
* @name getInstructions
* @public
* @function
* @memberof balena.models.deviceType
*
* @param {String} deviceTypeSlug - device type slug
* @fulfil {String[]} - step by step instructions for installing the host OS to the device
* @returns {Promise}
*
* @example
* balena.models.deviceType.getInstructions('raspberry-pi').then(function(instructions) {
* for (let instruction of instructions.values()) {
* console.log(instruction);
* }
* // Insert the sdcard to the host machine.
* // Write the BalenaOS file you downloaded to the sdcard. We recommend using <a href="http://www.etcher.io/">Etcher</a>.
* // Wait for writing of BalenaOS to complete.
* // Remove the sdcard from the host machine.
* // Insert the freshly flashed sdcard into the Raspberry Pi (v1 / Zero / Zero W).
* // Connect power to the Raspberry Pi (v1 / Zero / Zero W) to boot the device.
* });
*/
getInstructions: async (
deviceTypeSlug: string,
): Promise<any | string[]> => {
const contract = (
await exports.getBySlugOrName(deviceTypeSlug, { $select: 'contract' })
).contract;
if (!contract || !contract.partials) {
throw new Error(
`Instruction partials not defined for ${deviceTypeSlug}`,
);
}
const installMethod = calculateInstallMethod(contract);
const interpolatedDeviceType = interpolatedPartials(contract);
const interpolatedHostOS = interpolatedPartials(cloneDeep(BalenaOS), {
deviceType: interpolatedDeviceType,
});

return interpolatedHostOS.partials[installMethod];
},

/**
* @summary Get installation method on a given device type
* @name getInstallMethod
* @public
* @function
* @memberof balena.models.deviceType
*
* @param {String} deviceTypeSlug - device type slug
* @fulfil {String} - the installation method supported for the given device type slug
* @returns {Promise}
*
* @example
* balena.models.deviceType.getInstallMethod('raspberry-pi').then(function(method) {
* console.log(method);
* // externalBoot
* });
*/
getInstallMethod: async (
deviceTypeSlug: string,
): Promise<string | null> => {
const contract = (
await exports.getBySlugOrName(deviceTypeSlug, { $select: 'contract' })
).contract;
if (contract) {
return calculateInstallMethod(contract);
} else {
return null;
}
},
};

return exports;
Expand Down
6 changes: 4 additions & 2 deletions lib/types/contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { AnyObject } from '../../typings/utils';
import { AnyObject, Dictionary } from '../../typings/utils';

type Partials = Dictionary<string[] | Partials>;

export interface Contract {
slug: string;
Expand All @@ -15,5 +17,5 @@ export interface Contract {
requires?: string[];
provides?: string[];
composedOf?: AnyObject;
partials?: AnyObject;
partials?: Partials;
}
3 changes: 2 additions & 1 deletion lib/types/device-type-json.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Contract } from './contract';
import type { Dictionary } from '../../typings/utils';

/* types for the /device-types/v1 endppoints */

export interface DeviceType {
export interface DeviceType extends Contract {
slug: string;
name: string;
aliases: string[];
Expand Down
Loading

0 comments on commit 1bb297e

Please sign in to comment.