Merge pull request #979 from resin-io/978-oss-flow

Add support for the Opensource provisioning flow
This commit is contained in:
Thodoris Greasidis 2018-10-29 22:42:05 +02:00 committed by Tim Perry
commit e71f622453
11 changed files with 181 additions and 138 deletions

View File

@ -142,7 +142,7 @@ environment variable (in the same standard URL format).
- [os versions <type>](#os-versions-type-)
- [os download <type>](#os-download-type-)
- [os build-config <image> <device-type>](#os-build-config-image-device-type-)
- [os configure <image> [uuid] [deviceApiKey]](#os-configure-image-uuid-deviceapikey-)
- [os configure <image>](#os-configure-image-)
- [os initialize <image>](#os-initialize-image-)
- Config
@ -965,13 +965,12 @@ show advanced configuration options
the path to the output JSON file
## os configure <image> [uuid] [deviceApiKey]
## os configure <image>
Use this command to configure a previously downloaded operating system image for
the specific device or for an application generally.
Calling this command without --version is not recommended, and may fail in
future releases if the OS version cannot be inferred.
Calling this command with the exact version number of the targeted image is required.
Note that device api keys are only supported on ResinOS 2.0.3+.
@ -1125,8 +1124,7 @@ show advanced commands
Use this command to generate a config.json for a device or application.
Calling this command without --version is not recommended, and may fail in
future releases if the OS version cannot be inferred.
Calling this command with the exact version number of the targeted image is required.
This is interactive by default, but you can do this automatically without interactivity
by specifying an option for each question on the command line, if you know the questions

View File

@ -49,13 +49,17 @@ exports.optionalOsVersion =
description: 'a resinOS version'
parameter: 'version'
exports.osVersion = _.defaults
required: 'You have to specify an exact os version'
, exports.optionalOsVersion
exports.booleanDevice =
signature: 'device'
description: 'device'
boolean: true
alias: 'd'
exports.osVersion =
exports.osVersionOrSemver =
signature: 'version'
description: """
exact version number, or a valid semver range,

View File

@ -223,8 +223,7 @@ exports.generate =
help: '''
Use this command to generate a config.json for a device or application.
Calling this command without --version is not recommended, and may fail in
future releases if the OS version cannot be inferred.
Calling this command with the exact version number of the targeted image is required.
This is interactive by default, but you can do this automatically without interactivity
by specifying an option for each question on the command line, if you know the questions
@ -242,7 +241,7 @@ exports.generate =
--network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1
'''
options: [
commandOptions.optionalOsVersion
commandOptions.osVersion
commandOptions.optionalApplication
commandOptions.optionalDevice
commandOptions.optionalDeviceApiKey

View File

@ -395,7 +395,7 @@ exports.init =
commandOptions.optionalApplication
commandOptions.yes
commandOptions.advancedConfig
_.assign({}, commandOptions.osVersion, { signature: 'os-version', parameter: 'os-version' })
_.assign({}, commandOptions.osVersionOrSemver, { signature: 'os-version', parameter: 'os-version' })
commandOptions.drive
{
signature: 'config'

View File

@ -96,7 +96,7 @@ exports.download =
alias: 'o'
required: 'You have to specify the output location'
}
commandOptions.osVersion
commandOptions.osVersionOrSemver
]
action: (params, options, done) ->
Promise = require('bluebird')
@ -197,14 +197,13 @@ exports.buildConfig =
.nodeify(done)
exports.configure =
signature: 'os configure <image> [uuid] [deviceApiKey]'
signature: 'os configure <image>'
description: 'configure an os image'
help: '''
Use this command to configure a previously downloaded operating system image for
the specific device or for an application generally.
Calling this command without --version is not recommended, and may fail in
future releases if the OS version cannot be inferred.
Calling this command with the exact version number of the targeted image is required.
Note that device api keys are only supported on ResinOS 2.0.3+.
@ -224,7 +223,7 @@ exports.configure =
commandOptions.optionalApplication
commandOptions.optionalDevice
commandOptions.optionalDeviceApiKey
commandOptions.optionalOsVersion
commandOptions.osVersion
{
signature: 'config'
description: 'path to the config JSON file, see `resin os build-config`'
@ -232,7 +231,6 @@ exports.configure =
}
]
action: (params, options, done) ->
normalizeUuidProp(params)
normalizeUuidProp(options, 'device')
fs = require('fs')
Promise = require('bluebird')
@ -246,30 +244,20 @@ exports.configure =
if _.filter([
options.device
options.application
params.uuid
]).length != 1
patterns.exitWithExpectedError '''
To configure an image, you must provide exactly one of:
* A device, with --device <uuid>
* An application, with --app <appname>
* [Deprecated] A device, passing its uuid directly on the command line
See the help page for examples:
$ resin help os configure
'''
if params.uuid
console.warn(
'Directly passing a UUID to `resin os configure` is deprecated, and will stop working in future.\n' +
'Pass your device UUID with --device <uuid> instead.' +
if params.deviceApiKey
' Device api keys can be passed with --deviceApiKey.\n'
else '\n'
)
uuid = options.device || params.uuid
deviceApiKey = options.deviceApiKey || params.deviceApiKey
uuid = options.device
deviceApiKey = options.deviceApiKey
console.info('Configuring operating system image')

View File

@ -22,10 +22,13 @@ allDeviceTypes = undefined
getDeviceTypes = ->
Bluebird = require('bluebird')
_ = require('lodash')
if allDeviceTypes != undefined
return Bluebird.resolve(allDeviceTypes)
resin = require('resin-sdk').fromSharedOptions()
resin.models.config.getDeviceTypes()
.then (deviceTypes) ->
_.sortBy(deviceTypes, 'name')
.tap (dt) ->
allDeviceTypes = dt

View File

@ -304,8 +304,8 @@ createRelease = (apiEndpoint, auth, userId, appId, composition) ->
'is_created_by__user'
'__metadata'
])
_.keys serviceImages, (serviceName) ->
serviceImages[serviceName] = _.omit(serviceImages[serviceName], [
serviceImages = _.mapValues serviceImages, (serviceImage) ->
_.omit(serviceImage, [
'created_at'
'is_a_build_of__service'
'__metadata'
@ -418,7 +418,7 @@ exports.deployProject = (
localImage.remove()
.then ->
release.status = 'success'
.tapCatch (e) ->
.tapCatch ->
release.status = 'failed'
.finally ->
runloop = runSpinner(tty, spinner, "#{prefix}Saving release...")

View File

@ -13,77 +13,65 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as fs from 'fs';
import Promise = require('bluebird');
import ResinSdk = require('resin-sdk');
import deviceConfig = require('resin-device-config');
import * as semver from 'resin-semver';
const resin = ResinSdk.fromSharedOptions();
function readRootCa(): Promise<string | void> {
const caFile = process.env.NODE_EXTRA_CA_CERTS;
if (!caFile) {
return Promise.resolve();
}
return Promise.fromCallback(cb =>
fs.readFile(caFile, { encoding: 'utf8' }, cb),
)
.then(pem => Buffer.from(pem).toString('base64'))
.catch({ code: 'ENOENT' }, () => {});
}
type ImgConfig = {
applicationName: string;
applicationId: number;
deviceType: string;
userId: number;
username: string;
appUpdatePollInterval: number;
listenPort: number;
vpnPort: number;
apiEndpoint: string;
vpnEndpoint: string;
registryEndpoint: string;
deltaEndpoint: string;
pubnubSubscribeKey: string;
pubnubPublishKey: string;
mixpanelToken: string;
wifiSsid?: string;
wifiKey?: string;
// props for older OS versions
connectivity?: string;
files?: {
[filepath: string]: string;
};
// device specific config props
deviceId?: number;
uuid?: string;
registered_at?: number;
};
export function generateBaseConfig(
application: ResinSdk.Application,
options: { version?: string; appUpdatePollInterval?: number },
) {
if (options.appUpdatePollInterval) {
options = {
...options,
appUpdatePollInterval: options.appUpdatePollInterval * 60 * 1000,
};
}
options: { version: string; appUpdatePollInterval?: number },
): Promise<ImgConfig> {
options = {
...options,
appUpdatePollInterval: options.appUpdatePollInterval || 10,
};
return Promise.props({
userId: resin.auth.getUserId(),
username: resin.auth.whoami(),
apiUrl: resin.settings.get('apiUrl'),
vpnUrl: resin.settings.get('vpnUrl'),
registryUrl: resin.settings.get('registryUrl'),
deltaUrl: resin.settings.get('deltaUrl'),
apiConfig: resin.models.config.getAll(),
rootCA: readRootCa().catch(() => {
console.warn('Could not read root CA');
}),
}).then(results => {
return deviceConfig.generate(
{
application,
user: {
id: results.userId,
username: results.username,
},
endpoints: {
api: results.apiUrl,
vpn: results.vpnUrl,
registry: results.registryUrl,
delta: results.deltaUrl,
},
pubnub: results.apiConfig.pubnub,
mixpanel: {
token: results.apiConfig.mixpanelToken,
},
balenaRootCA: results.rootCA,
},
options,
);
const promise = resin.models.os.getConfig(
application.app_name,
options,
) as Promise<ImgConfig & { apiKey?: string }>;
return promise.tap(config => {
// os.getConfig always returns a config for an app
delete config.apiKey;
});
}
export function generateApplicationConfig(
application: ResinSdk.Application,
options: { version?: string },
options: { version: string },
) {
return generateBaseConfig(application, options).tap(config => {
if (semver.satisfies(options.version, '>=2.7.8')) {
@ -97,7 +85,7 @@ export function generateApplicationConfig(
export function generateDeviceConfig(
device: ResinSdk.Device & { belongs_to__application: ResinSdk.PineDeferred },
deviceApiKey: string | true | null,
options: { version?: string },
options: { version: string },
) {
return resin.models.application
.get(device.belongs_to__application.__id)

View File

@ -99,6 +99,7 @@ export function askLoginType() {
export function selectDeviceType() {
return resin.models.config.getDeviceTypes().then(deviceTypes => {
deviceTypes = _.sortBy(deviceTypes, 'name');
return form.ask({
message: 'Device Type',
type: 'list',

View File

@ -1,5 +1,5 @@
import { stripIndent } from 'common-tags';
import { ResinSDK, Application } from 'resin-sdk';
import * as ResinSdk from 'resin-sdk';
import Logger = require('./logger');
@ -10,7 +10,7 @@ const MIN_RESINOS_VERSION = 'v2.14.0';
export async function join(
logger: Logger,
sdk: ResinSDK,
sdk: ResinSdk.ResinSDK,
deviceHostnameOrIp?: string,
appName?: string,
): Promise<void> {
@ -39,8 +39,14 @@ export async function join(
app.device_type = deviceType;
}
logger.logDebug('Determining device OS version...');
const deviceOsVersion = await getOsVersion(deviceIp);
logger.logDebug(`Device OS version: ${deviceOsVersion}`);
logger.logDebug('Generating application config...');
const config = await generateApplicationConfig(sdk, app);
const config = await generateApplicationConfig(sdk, app, {
version: deviceOsVersion,
});
logger.logDebug(`Using config: ${JSON.stringify(config, null, 2)}`);
logger.logDebug('Configuring...');
@ -53,7 +59,7 @@ export async function join(
export async function leave(
logger: Logger,
_sdk: ResinSDK,
_sdk: ResinSdk.ResinSDK,
deviceHostnameOrIp?: string,
): Promise<void> {
logger.logDebug('Determining device...');
@ -123,6 +129,15 @@ async function getDeviceType(deviceIp: string): Promise<string> {
return match[1];
}
async function getOsVersion(deviceIp: string): Promise<string> {
const output = await execBuffered(deviceIp, 'cat /etc/os-release');
const match = /^VERSION_ID="([^"]+)"$/m.exec(output);
if (!match) {
throw new Error('Failed to determine OS version ID');
}
return match[1];
}
async function getOrSelectLocalDevice(deviceIp?: string): Promise<string> {
if (deviceIp) {
return deviceIp;
@ -158,14 +173,58 @@ async function getOrSelectLocalDevice(deviceIp?: string): Promise<string> {
return ip;
}
async function getApplicationsWithOptionalUsers(
sdk: ResinSdk.ResinSDK,
options: ResinSdk.PineOptionsFor<ResinSdk.Application>,
) {
const _ = await import('lodash');
let applications = await sdk.models.application.getAll(options);
// If we got more than one application with the same name it means that the
// user has access to a collab app with the same name as a personal app.
if (applications.length !== _.uniqBy(applications, 'app_name').length) {
options = _.merge(_.cloneDeep(options), {
$expand: { user: { $select: ['username'] } },
});
applications = await sdk.models.application.getAll(options);
}
return applications;
}
async function selectAppFromList(applications: ResinSdk.Application[]) {
const _ = await import('lodash');
const { selectFromList } = await import('../utils/patterns');
// If we got more than one application with the same name it means that the
// user has access to a collab app with the same name as a personal app. We
// present a list to the user which shows the fully qualified application
// name (user/appname) and allows them to select.
const hasSameNameApps =
applications.length !== _.uniqBy(applications, 'app_name').length;
return selectFromList(
hasSameNameApps
? 'Found multiple applications with that name; please select the one to use'
: 'Select application',
_.map(applications, app => {
let name = app.app_name;
if (hasSameNameApps) {
const owner = _.get(app, 'user[0].username');
name = `${owner}/${app.app_name}`;
}
return _.merge({ name }, app);
}),
);
}
async function getOrSelectApplication(
sdk: ResinSDK,
sdk: ResinSdk.ResinSDK,
deviceType: string,
appName?: string,
): Promise<Application> {
): Promise<ResinSdk.Application> {
const _ = await import('lodash');
const form = await import('resin-cli-form');
const { selectFromList } = await import('../utils/patterns');
const allDeviceTypes = await sdk.models.config.getDeviceTypes();
const deviceTypeManifest = _.find(allDeviceTypes, { slug: deviceType });
@ -177,14 +236,14 @@ async function getOrSelectApplication(
.map(type => type.slug)
.value();
const options: any = {
$expand: { user: { $select: ['username'] } },
$filter: { device_type: { $in: compatibleDeviceTypes } },
};
if (!appName) {
const options = {
$filter: { device_type: { $in: compatibleDeviceTypes } },
};
// No application specified, show a list to select one.
const applications = await sdk.models.application.getAll(options);
const applications = await getApplicationsWithOptionalUsers(sdk, options);
if (applications.length === 0) {
const shouldCreateApp = await form.ask({
message:
@ -198,29 +257,31 @@ async function getOrSelectApplication(
}
process.exit(1);
}
return selectFromList(
'Select application',
_.map(applications, app => _.merge({ name: app.app_name }, app)),
);
return selectAppFromList(applications);
}
// We're given an application; resolve it if it's ambiguous and also validate
// it's of appropriate device type.
options.$filter = { app_name: appName };
const options: ResinSdk.PineOptionsFor<ResinSdk.Application> = {};
// Check for an app of the form `user/application` and update the API query.
const match = appName.split('/');
if (match.length > 1) {
// These will match at most one app, so we'll return early.
options.$expand.user.$filter = { username: match[0] };
options.$filter.app_name = match[1];
// These will match at most one app
options.$expand = {
user: {
$select: ['username'],
$filter: { username: match[0] },
},
};
options.$filter = { app_name: match[1] };
} else {
// We're given an application; resolve it if it's ambiguous and also validate
// it's of appropriate device type.
options.$filter = { app_name: appName };
}
// Fetch all applications with the given name that are accessible to the user
const applications = await sdk.pine.get<Application>({
resource: 'application',
options,
});
const applications = await getApplicationsWithOptionalUsers(sdk, options);
if (applications.length === 0) {
const shouldCreateApp = await form.ask({
@ -231,7 +292,8 @@ async function getOrSelectApplication(
default: true,
});
if (shouldCreateApp) {
return createApplication(sdk, deviceType, options.$filter.app_name);
return createApplication(sdk, deviceType, options.$filter
.app_name as string);
}
process.exit(1);
}
@ -242,36 +304,27 @@ async function getOrSelectApplication(
_.includes(compatibleDeviceTypes, app.device_type),
);
if (validApplications.length === 0) {
throw new Error('No application found with a matching device type');
}
if (validApplications.length === 1) {
return validApplications[0];
}
// If we got more than one application with the same name it means that the
// user has access to a collab app with the same name as a personal app. We
// present a list to the user which shows the fully qualified application
// name (user/appname) and allows them to select
return selectFromList(
'Found multiple applications with that name; please select the one to use',
_.map(validApplications, app => {
const owner = _.get(app, 'user[0].username');
return _.merge({ name: `${owner}/${app.app_name}` }, app);
}),
);
return selectAppFromList(applications);
}
async function createApplication(
sdk: ResinSDK,
sdk: ResinSdk.ResinSDK,
deviceType: string,
name?: string,
): Promise<Application> {
): Promise<ResinSdk.Application> {
const form = await import('resin-cli-form');
const validation = await import('./validation');
const patterns = await import('./patterns');
const user = await sdk.auth.getUserId();
const queryOptions = {
$filter: { user },
};
const appName = await new Promise<string>(async (resolve, reject) => {
while (true) {
@ -284,7 +337,9 @@ async function createApplication(
});
try {
await sdk.models.application.get(appName, queryOptions);
await sdk.models.application.get(appName, {
$filter: { user },
});
patterns.printErrorMessage(
'You already have an application with that name; please choose another.',
);
@ -304,14 +359,21 @@ async function createApplication(
});
}
async function generateApplicationConfig(sdk: ResinSDK, app: Application) {
async function generateApplicationConfig(
sdk: ResinSdk.ResinSDK,
app: ResinSdk.Application,
options: { version: string },
) {
const form = await import('resin-cli-form');
const { generateApplicationConfig: configGen } = await import('./config');
const manifest = await sdk.models.device.getManifestBySlug(app.device_type);
const opts =
manifest.options && manifest.options.filter(opt => opt.name !== 'network');
const values = await form.run(opts);
const values = {
...(await form.run(opts)),
...options,
};
const config = await configGen(app, values);
if (config.connectivity === 'connman') {

View File

@ -153,7 +153,7 @@
"resin-multibuild": "^0.9.0",
"resin-preload": "^7.0.0",
"resin-release": "^1.2.0",
"resin-sdk": "10.0.0-beta2",
"resin-sdk": "11.0.0-balena-sdk-78e343d605d323cff37b039e8e40cae05b52fb36",
"resin-sdk-preconfigured": "^6.9.0",
"resin-semver": "^1.3.0",
"resin-settings-client": "^3.6.1",