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 Feb 3, 2022
1 parent 9995097 commit 53d78eb
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 4 deletions.
76 changes: 76 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ const sdk = fromSharedOptions();
* [.getAllSupported([options])](#balena.models.deviceType.getAllSupported) ⇒ <code>Promise</code>
* [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ <code>Promise</code>
* [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ <code>Promise</code>
* [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ <code>Promise</code>
* [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ <code>Promise</code>
* [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ <code>Promise</code>
* [.apiKey](#balena.models.apiKey) : <code>object</code>
* [.create(name, [description])](#balena.models.apiKey.create) ⇒ <code>Promise</code>
Expand Down Expand Up @@ -639,6 +641,8 @@ balena.models.device.get(123).catch(function (error) {
* [.getAllSupported([options])](#balena.models.deviceType.getAllSupported) ⇒ <code>Promise</code>
* [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ <code>Promise</code>
* [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ <code>Promise</code>
* [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ <code>Promise</code>
* [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ <code>Promise</code>
* [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ <code>Promise</code>
* [.apiKey](#balena.models.apiKey) : <code>object</code>
* [.create(name, [description])](#balena.models.apiKey.create) ⇒ <code>Promise</code>
Expand Down Expand Up @@ -5112,6 +5116,8 @@ balena.models.device.restartService('7cf02a6', 123, function(error) {
* [.getAllSupported([options])](#balena.models.deviceType.getAllSupported) ⇒ <code>Promise</code>
* [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ <code>Promise</code>
* [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ <code>Promise</code>
* [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ <code>Promise</code>
* [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ <code>Promise</code>
* [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ <code>Promise</code>

<a name="balena.models.deviceType.get"></a>
Expand Down Expand Up @@ -5266,6 +5272,76 @@ balena.models.deviceType.getName('raspberry-pi', function(error, deviceTypeName)
// 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.
});
```
**Example**
```js
balena.models.deviceType.getInstructions('raspberry-pi', baseInstructions = {
`Use the form on the left to configure and download {{name}} for your new {{deviceType.name}}.
{{#each instructions}}
{{{this}}}
{{/each}}
Your device should appear in your application dashboard within a few minutes. Have fun!`
}).then(function(instructions) {
for (let instruction of instructions.values()) {
console.log(instruction);
}
// Use the form on the left to configure and download BalenaOS for your new Raspberry Pi (v1 / Zero / Zero W).
// 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.
// Your device should appear in your application dashboard within a few minutes. Have fun!
});
```
<a name="balena.models.deviceType.getSlugByName"></a>

##### deviceType.getSlugByName(deviceTypeName) ⇒ <code>Promise</code>
Expand Down
55 changes: 55 additions & 0 deletions lib/models/balenaos-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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: {
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.installation}} to the host machine.`,
`Write the {{name}} file you downloaded to the {{deviceType.data.media.installation}}. We recommend using <a href="http://www.etcher.io/">Etcher</a>.`,
`Wait for writing of {{name}} to complete.`,
`Remove the {{deviceType.data.media.installation}} from the host machine.`,
`Insert the freshly flashed {{deviceType.data.media.installation}} 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.installation}} from the {{deviceType.name}}.`,
`{{#each deviceType.partials.bootDeviceInternal}}{{{this}}} {{/each}}`,
`{{{deviceType.partials.bootDevice}}} to boot the device.`,
],
externalBoot: [
`Insert the {{deviceType.data.media.installation}} to the host machine.`,
`Write the {{name}} file you downloaded to the {{deviceType.data.media.installation}}. We recommend using <a href="http://www.etcher.io/">Etcher</a>.`,
`Wait for writing of {{name}} to complete.`,
`Remove the {{deviceType.data.media.installation}} from the host machine.`,
`Insert the freshly flashed {{deviceType.data.media.installation}} 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`,
`{{#each deviceType.partials.jetsonNotes}}{{{this}}} {{/each}}`,
`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.`,
],
custom: [
`{{#each deviceType.partials.instructions}}
{{{this}}}
{{/each}}`,
`{{{deviceType.partials.bootDevice}}} to boot the device.`,
],
},
};

export { BalenaOS };
138 changes: 137 additions & 1 deletion lib/models/device-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
Expand All @@ -16,8 +16,32 @@ 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 interpolatedPartials = (contract: Contract, initial: any = {}) => {
const fullInitial = { ...contract, ...initial };
if (contract.partials) {
const partials = contract.partials;
return Object.keys(partials).reduce(
(interpolated: any, partialKey) => {
interpolated.partials[partialKey] = partials[partialKey].map(
(partial: string) => Handlebars.compile(partial)(interpolated),
);
return interpolated;
},
{ ...fullInitial },
);
} else {
return fullInitial;
}
};

const getDeviceTypeModel = function (deps: InjectedDependenciesParam) {
const { pine } = deps;
Expand Down Expand Up @@ -282,6 +306,118 @@ const getDeviceTypeModel = function (deps: InjectedDependenciesParam) {
).name;
},

/**
* @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.
* });
* @example
* balena.models.deviceType.getInstructions('raspberry-pi', baseInstructions = {
* `Use the form on the left to configure and download {{name}} for your new {{deviceType.name}}.
* {{#each instructions}}
* {{{this}}}
* {{/each}}
* Your device should appear in your application dashboard within a few minutes. Have fun!`
* }).then(function(instructions) {
* for (let instruction of instructions.values()) {
* console.log(instruction);
* }
* // Use the form on the left to configure and download BalenaOS for your new Raspberry Pi (v1 / Zero / Zero W).
* // 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.
* // Your device should appear in your application dashboard within a few minutes. Have fun!
* });
*/
getInstructions: async (
deviceTypeSlug: string,
baseInstructions?: string,
): Promise<string[]> => {
const contract = (
await exports.getBySlugOrName(deviceTypeSlug, { $select: 'contract' })
).contract;
if (contract) {
const installMethod = contract?.data?.installation?.method;
if (!installMethod || !contract.partials) {
throw new Error(
`Install method or instruction partials not defined for ${deviceTypeSlug}`,
);
}
const interpolatedDeviceType = interpolatedPartials(contract);
const interpolatedHostOS = interpolatedPartials(cloneDeep(BalenaOS), {
deviceType: interpolatedDeviceType,
});

if (baseInstructions) {
return Handlebars.compile(baseInstructions)({
...interpolatedHostOS,
instructions: interpolatedHostOS.partials[installMethod],
})
.split('\n')
.map((s) => s.trim())
.filter((s) => s);
} else {
return interpolatedHostOS.partials[installMethod];
}
} else {
return [];
}
},

/**
* @summary Get device slug
* @name getSlugByName
Expand Down
4 changes: 2 additions & 2 deletions lib/types/contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AnyObject } from '../../typings/utils';
import { AnyObject, Dictionary } from '../../typings/utils';

export interface Contract {
slug: string;
Expand All @@ -15,5 +15,5 @@ export interface Contract {
requires?: string[];
provides?: string[];
composedOf?: AnyObject;
partials?: AnyObject;
partials?: Dictionary<string[]>;
}
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"balena-request": "^11.5.0",
"balena-semver": "^2.3.0",
"balena-settings-client": "^4.0.6",
"handlebars": "^4.7.7",
"lodash": "^4.17.21",
"memoizee": "^0.4.15",
"moment": "^2.29.1",
Expand Down

0 comments on commit 53d78eb

Please sign in to comment.