diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 936f9a4ce..cb0211883 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -263,6 +263,8 @@ const sdk = fromSharedOptions(); * [.getAllSupported([options])](#balena.models.deviceType.getAllSupported) ⇒ Promise * [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ Promise * [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ Promise + * [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ Promise + * [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ Promise * [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ Promise * [.apiKey](#balena.models.apiKey) : object * [.create(name, [description])](#balena.models.apiKey.create) ⇒ Promise @@ -639,6 +641,8 @@ balena.models.device.get(123).catch(function (error) { * [.getAllSupported([options])](#balena.models.deviceType.getAllSupported) ⇒ Promise * [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ Promise * [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ Promise + * [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ Promise + * [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ Promise * [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ Promise * [.apiKey](#balena.models.apiKey) : object * [.create(name, [description])](#balena.models.apiKey.create) ⇒ Promise @@ -5112,6 +5116,8 @@ balena.models.device.restartService('7cf02a6', 123, function(error) { * [.getAllSupported([options])](#balena.models.deviceType.getAllSupported) ⇒ Promise * [.getBySlugOrName(slugOrName)](#balena.models.deviceType.getBySlugOrName) ⇒ Promise * [.getName(deviceTypeSlug)](#balena.models.deviceType.getName) ⇒ Promise + * [.getInterpolatedPartials(deviceTypeSlug, initial)](#balena.models.deviceType.getInterpolatedPartials) ⇒ Promise + * [.getInstructions(deviceTypeSlug)](#balena.models.deviceType.getInstructions) ⇒ Promise * [.getSlugByName(deviceTypeName)](#balena.models.deviceType.getSlugByName) ⇒ Promise @@ -5266,6 +5272,76 @@ balena.models.deviceType.getName('raspberry-pi', function(error, deviceTypeName) // Raspberry Pi }); ``` + + +##### deviceType.getInterpolatedPartials(deviceTypeSlug, initial) ⇒ Promise +**Kind**: static method of [deviceType](#balena.models.deviceType) +**Summary**: Get a contract with resolved partial templates +**Access**: public +**Fulfil**: Contract - device type contract with resolved partials + +| Param | Type | Description | +| --- | --- | --- | +| deviceTypeSlug | String | device type slug | +| initial | any | 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)"] +}); +``` + + +##### deviceType.getInstructions(deviceTypeSlug) ⇒ Promise +**Kind**: static method of [deviceType](#balena.models.deviceType) +**Summary**: Get instructions for installing a host OS on a given device type +**Access**: public +**Fulfil**: String[] - step by step instructions for installing the host OS to the device + +| Param | Type | Description | +| --- | --- | --- | +| deviceTypeSlug | String | 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 Etcher. + // 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 Etcher. + // 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! +}); +``` ##### deviceType.getSlugByName(deviceTypeName) ⇒ Promise diff --git a/lib/models/balenaos-contract.ts b/lib/models/balenaos-contract.ts new file mode 100644 index 000000000..015873e55 --- /dev/null +++ b/lib/models/balenaos-contract.ts @@ -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 Etcher.`, + `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 Etcher.`, + `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}}.`, + `Warning! 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 Etcher.`, + `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 https://github.com/balena-os/jetson-flash.`, + `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 }; diff --git a/lib/models/device-type.ts b/lib/models/device-type.ts index 2bf7607cc..3766adaec 100644 --- a/lib/models/device-type.ts +++ b/lib/models/device-type.ts @@ -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, @@ -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; @@ -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 => { + 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 Etcher. + * // 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 Etcher. + * // 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 => { + 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 diff --git a/lib/types/contract.ts b/lib/types/contract.ts index d5e9b85af..4c0e371e7 100644 --- a/lib/types/contract.ts +++ b/lib/types/contract.ts @@ -1,4 +1,4 @@ -import { AnyObject } from '../../typings/utils'; +import { AnyObject, Dictionary } from '../../typings/utils'; export interface Contract { slug: string; @@ -15,5 +15,5 @@ export interface Contract { requires?: string[]; provides?: string[]; composedOf?: AnyObject; - partials?: AnyObject; + partials?: Dictionary; } diff --git a/lib/types/device-type-json.ts b/lib/types/device-type-json.ts index 8eae44b05..71500c31c 100644 --- a/lib/types/device-type-json.ts +++ b/lib/types/device-type-json.ts @@ -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[]; diff --git a/package.json b/package.json index 26df9ec92..a32235c42 100644 --- a/package.json +++ b/package.json @@ -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",