mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-30 10:38:50 +00:00
Auto-merge for PR #868 via VersionBot
Add push command which starts a build on remote resin servers
This commit is contained in:
commit
22e74983b0
@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file
|
||||
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## v7.4.0 - 2018-05-10
|
||||
|
||||
* Add push command which starts a build on remote resin servers #868 [Cameron Diver]
|
||||
|
||||
## v7.3.8 - 2018-05-03
|
||||
|
||||
* Catch require errors and provide helpful instructions #874 [Tim Perry]
|
||||
|
@ -37,3 +37,4 @@ module.exports =
|
||||
deploy: require('./deploy')
|
||||
util: require('./util')
|
||||
preload: require('./preload')
|
||||
push: require('./push')
|
||||
|
142
lib/actions/push.ts
Normal file
142
lib/actions/push.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
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 { CommandDefinition } from 'capitano';
|
||||
import { ResinSDK } from 'resin-sdk';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
async function getAppOwner(sdk: ResinSDK, appName: string) {
|
||||
const {
|
||||
exitWithExpectedError,
|
||||
selectFromList,
|
||||
} = await import('../utils/patterns');
|
||||
const _ = await import('lodash');
|
||||
|
||||
const applications = await sdk.models.application.getAll({
|
||||
$expand: {
|
||||
user: {
|
||||
$select: ['username'],
|
||||
},
|
||||
},
|
||||
$filter: {
|
||||
app_name: appName,
|
||||
},
|
||||
$select: ['id'],
|
||||
});
|
||||
|
||||
if (applications == null || applications.length === 0) {
|
||||
exitWithExpectedError(
|
||||
stripIndent`
|
||||
No applications found with name: ${appName}.
|
||||
|
||||
This could mean that the application does not exist, or you do
|
||||
not have the permissions required to access it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (applications.length === 1) {
|
||||
return _.get(applications, '[0].user[0].username');
|
||||
}
|
||||
|
||||
// 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 entries = _.map(applications, app => {
|
||||
const username = _.get(app, 'user[0].username');
|
||||
return {
|
||||
name: `${username}/${appName}`,
|
||||
extra: username,
|
||||
};
|
||||
});
|
||||
|
||||
const selected = await selectFromList(
|
||||
`${
|
||||
entries.length
|
||||
} applications found with that name, please select the application you would like to push to`,
|
||||
entries,
|
||||
);
|
||||
|
||||
return selected.extra;
|
||||
}
|
||||
|
||||
export const push: CommandDefinition<
|
||||
{
|
||||
application: string;
|
||||
},
|
||||
{
|
||||
source: string;
|
||||
}
|
||||
> = {
|
||||
signature: 'push <application>',
|
||||
description: 'Start a remote build on the resin.io cloud build servers',
|
||||
help: stripIndent`
|
||||
This command can be used to start a build on the remote
|
||||
resin.io cloud builders. The given source directory will be sent to the
|
||||
resin.io builder, and the build will proceed. This can be used as a drop-in
|
||||
replacement for git push to deploy.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin push myApp
|
||||
$ resin push myApp --source <source directory>
|
||||
$ resin push myApp -s <source directory>
|
||||
`,
|
||||
permission: 'user',
|
||||
options: [
|
||||
{
|
||||
signature: 'source',
|
||||
alias: 's',
|
||||
description:
|
||||
'The source that should be sent to the resin builder to be built (defaults to the current directory)',
|
||||
parameter: 'source',
|
||||
},
|
||||
],
|
||||
async action(params, options, done) {
|
||||
const sdk = (await import('resin-sdk')).fromSharedOptions();
|
||||
const Bluebird = await import('bluebird');
|
||||
const remote = await import('../utils/remote-build');
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
|
||||
const app: string | null = params.application;
|
||||
if (app == null) {
|
||||
exitWithExpectedError('You must specify an application');
|
||||
}
|
||||
|
||||
const source = options.source || '.';
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[debug] Using ${source} as build source`);
|
||||
}
|
||||
|
||||
Bluebird.join(
|
||||
sdk.auth.getToken(),
|
||||
sdk.settings.get('resinUrl'),
|
||||
getAppOwner(sdk, app),
|
||||
(token, baseUrl, owner) => {
|
||||
const args = {
|
||||
app,
|
||||
owner,
|
||||
source,
|
||||
auth: token,
|
||||
baseUrl,
|
||||
sdk,
|
||||
};
|
||||
|
||||
return remote.startRemoteBuild(args);
|
||||
},
|
||||
).nodeify(done);
|
||||
},
|
||||
};
|
@ -212,6 +212,9 @@ capitano.command(actions.internal.osInit)
|
||||
capitano.command(actions.build)
|
||||
capitano.command(actions.deploy)
|
||||
|
||||
#------------ Push/remote builds -------
|
||||
capitano.command(actions.push.push)
|
||||
|
||||
update.notify()
|
||||
|
||||
cli = capitano.parse(process.argv)
|
||||
|
@ -102,7 +102,7 @@ toPosixPath = (systemPath) ->
|
||||
path = require('path')
|
||||
systemPath.replace(new RegExp('\\' + path.sep, 'g'), '/')
|
||||
|
||||
tarDirectory = (dir) ->
|
||||
exports.tarDirectory = tarDirectory = (dir) ->
|
||||
tar = require('tar-stream')
|
||||
klaw = require('klaw')
|
||||
path = require('path')
|
||||
|
3
lib/utils/compose.d.ts
vendored
Normal file
3
lib/utils/compose.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import * as Stream from 'stream';
|
||||
|
||||
export function tarDirectory(source: string): Promise<Stream.Readable>;
|
@ -23,6 +23,11 @@ import chalk from 'chalk';
|
||||
import validation = require('./validation');
|
||||
import messages = require('./messages');
|
||||
|
||||
export interface ListSelectionEntry {
|
||||
name: string;
|
||||
extra: any;
|
||||
}
|
||||
|
||||
export function authenticate(options: {}): Promise<void> {
|
||||
return form
|
||||
.run(
|
||||
@ -245,6 +250,20 @@ export function inferOrSelectDevice(preferredUuid: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFromList(
|
||||
message: string,
|
||||
selections: ListSelectionEntry[],
|
||||
): Promise<ListSelectionEntry> {
|
||||
return form.ask({
|
||||
message,
|
||||
type: 'list',
|
||||
choices: _.map(selections, s => ({
|
||||
name: s.name,
|
||||
value: s,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function printErrorMessage(message: string) {
|
||||
console.error(chalk.red(message));
|
||||
console.error(chalk.red(`\n${messages.getHelp}\n`));
|
||||
|
215
lib/utils/remote-build.ts
Normal file
215
lib/utils/remote-build.ts
Normal file
@ -0,0 +1,215 @@
|
||||
/*
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
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 JSONStream from 'JSONStream';
|
||||
import * as request from 'request';
|
||||
import { ResinSDK } from 'resin-sdk';
|
||||
import * as Stream from 'stream';
|
||||
|
||||
import { tarDirectory } from './compose';
|
||||
|
||||
const DEBUG_MODE = !!process.env.DEBUG;
|
||||
|
||||
const CURSOR_METADATA_REGEX = /([a-z]+)([0-9]+)?/;
|
||||
const TRIM_REGEX = /\n+$/;
|
||||
|
||||
export interface RemoteBuild {
|
||||
app: string;
|
||||
owner: string;
|
||||
source: string;
|
||||
auth: string;
|
||||
baseUrl: string;
|
||||
|
||||
sdk: ResinSDK;
|
||||
|
||||
// For internal use
|
||||
releaseId?: number;
|
||||
}
|
||||
|
||||
interface BuilderMessage {
|
||||
message: string;
|
||||
type?: string;
|
||||
replace?: boolean;
|
||||
// These will be set when the type === 'metadata'
|
||||
resource?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
async function getBuilderEndpoint(
|
||||
baseUrl: string,
|
||||
owner: string,
|
||||
app: string,
|
||||
): Promise<string> {
|
||||
const querystring = await import('querystring');
|
||||
const args = querystring.stringify({ owner, app });
|
||||
return `https://builder.${baseUrl}/v3/build?${args}`;
|
||||
}
|
||||
|
||||
export async function startRemoteBuild(build: RemoteBuild): Promise<void> {
|
||||
const Bluebird = await import('bluebird');
|
||||
|
||||
return new Bluebird(async (resolve, reject) => {
|
||||
const stream = await getRequestStream(build);
|
||||
|
||||
// Special windows handling (win64 also reports win32)
|
||||
if (process.platform === 'win32') {
|
||||
const readline = (await import('readline')).createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
readline.on('SIGINT', () => process.emit('SIGINT'));
|
||||
}
|
||||
|
||||
// Setup interrupt handlers so we can cancel the build if the user presses
|
||||
// ctrl+c
|
||||
|
||||
// This is necessary because the `exit-hook` module is used by several
|
||||
// dependencies, and will exit without calling the following handler.
|
||||
// Once https://github.com/resin-io/resin-cli/issues/867 has been solved,
|
||||
// we are free to (and definitely should) remove the below line
|
||||
process.removeAllListeners('SIGINT');
|
||||
process.on('SIGINT', () => {
|
||||
process.stderr.write('Received SIGINT, cleaning up. Please wait.\n');
|
||||
cancelBuildIfNecessary(build).then(() => {
|
||||
stream.end();
|
||||
process.exit(130);
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('data', getBuilderMessageHandler(build));
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
}).return();
|
||||
}
|
||||
|
||||
async function handleBuilderMetadata(obj: BuilderMessage, build: RemoteBuild) {
|
||||
const { stripIndent } = await import('common-tags');
|
||||
|
||||
switch (obj.resource) {
|
||||
case 'cursor':
|
||||
const readline = await import('readline');
|
||||
|
||||
if (obj.value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = obj.value.match(CURSOR_METADATA_REGEX);
|
||||
|
||||
if (!match) {
|
||||
// FIXME: Make this error nicer.
|
||||
console.log(
|
||||
stripIndent`
|
||||
Warning: ignoring unknown builder command. You may experience
|
||||
odd build output. Maybe you need to update resin-cli?`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = match[1];
|
||||
const amount = match[2] || 1;
|
||||
|
||||
switch (value) {
|
||||
case 'erase':
|
||||
readline.clearLine(process.stdout, 0);
|
||||
process.stdout.write('\r');
|
||||
break;
|
||||
case 'up':
|
||||
readline.moveCursor(process.stdout, 0, -amount);
|
||||
break;
|
||||
case 'down':
|
||||
readline.moveCursor(process.stdout, 0, amount);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case 'buildLogId':
|
||||
// The name of this resource is slightly dated, but this is the release
|
||||
// id from the API. We need to save this so that if the user ctrl+c's the
|
||||
// build we can cancel it on the API.
|
||||
build.releaseId = parseInt(obj.value!, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function getBuilderMessageHandler(
|
||||
build: RemoteBuild,
|
||||
): (obj: BuilderMessage) => Promise<void> {
|
||||
return async (obj: BuilderMessage) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(`[debug] handling message: ${JSON.stringify(obj)}`);
|
||||
}
|
||||
if (obj.type != null && obj.type === 'metadata') {
|
||||
return handleBuilderMetadata(obj, build);
|
||||
}
|
||||
if (obj.message) {
|
||||
const readline = await import('readline');
|
||||
readline.clearLine(process.stdout, 0);
|
||||
|
||||
const message = obj.message.replace(TRIM_REGEX, '');
|
||||
if (obj.replace) {
|
||||
process.stdout.write(`\r${message}`);
|
||||
} else {
|
||||
process.stdout.write(`\r${message}\n`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelBuildIfNecessary(build: RemoteBuild): Promise<void> {
|
||||
if (build.releaseId != null) {
|
||||
await build.sdk.pine.patch({
|
||||
resource: 'release',
|
||||
id: build.releaseId,
|
||||
body: {
|
||||
status: 'cancelled',
|
||||
end_timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getRequestStream(build: RemoteBuild): Promise<Stream.Duplex> {
|
||||
const path = await import('path');
|
||||
const visuals = await import('resin-cli-visuals');
|
||||
|
||||
const tarSpinner = new visuals.Spinner('Packaging the project source...');
|
||||
tarSpinner.start();
|
||||
// Tar the directory so that we can send it to the builder
|
||||
const tarStream = await tarDirectory(path.resolve(build.source));
|
||||
tarSpinner.stop();
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[debug] Opening builder connection');
|
||||
}
|
||||
const post = request.post({
|
||||
url: await getBuilderEndpoint(build.baseUrl, build.owner, build.app),
|
||||
auth: {
|
||||
bearer: build.auth,
|
||||
},
|
||||
});
|
||||
|
||||
const uploadSpinner = new visuals.Spinner(
|
||||
'Uploading source package to resin cloud',
|
||||
);
|
||||
uploadSpinner.start();
|
||||
|
||||
tarStream.pipe(post);
|
||||
|
||||
const parseStream = post.pipe(JSONStream.parse('*'));
|
||||
parseStream.on('data', () => uploadSpinner.stop());
|
||||
return parseStream as Stream.Duplex;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "resin-cli",
|
||||
"version": "7.3.8",
|
||||
"version": "7.4.0",
|
||||
"description": "The official resin.io CLI tool",
|
||||
"main": "./build/actions/index.js",
|
||||
"homepage": "https://github.com/resin-io/resin-cli",
|
||||
@ -89,6 +89,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@resin.io/valid-email": "^0.1.0",
|
||||
"@types/stream-to-promise": "^2.2.0",
|
||||
"ansi-escapes": "^2.0.0",
|
||||
"any-promise": "^1.3.0",
|
||||
"archiver": "^2.1.0",
|
||||
@ -117,6 +118,7 @@
|
||||
"inquirer": "^3.1.1",
|
||||
"is-root": "^1.0.0",
|
||||
"js-yaml": "^3.10.0",
|
||||
"JSONStream": "^1.0.3",
|
||||
"klaw": "^1.3.1",
|
||||
"lodash": "^4.17.4",
|
||||
"mixpanel": "^0.4.0",
|
||||
@ -146,7 +148,7 @@
|
||||
"resin-multibuild": "^0.5.1",
|
||||
"resin-preload": "^6.2.0",
|
||||
"resin-release": "^1.2.0",
|
||||
"resin-sdk": "9.0.0-beta17",
|
||||
"resin-sdk": "9.0.0-beta18",
|
||||
"resin-sdk-preconfigured": "^6.9.0",
|
||||
"resin-settings-client": "^3.6.1",
|
||||
"resin-stream-logger": "^0.1.0",
|
||||
|
53
typings/JSONStream.d.ts
vendored
Normal file
53
typings/JSONStream.d.ts
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
// These are the DefinitelyTyped typings for JSONStream, but because of this
|
||||
// mismatch in case of jsonstream and JSONStream, it is necessary to include
|
||||
// them this way, with an upper case module declaration. They have also
|
||||
// been slightly edited to remove the extra `declare` keyworks (which are
|
||||
// not necessary or accepted inside a `declare module '...' {` block)
|
||||
declare module 'JSONStream' {
|
||||
// Type definitions for JSONStream v0.8.0
|
||||
// Project: https://github.com/dominictarr/JSONStream
|
||||
// Definitions by: Bart van der Schoor <https://github.com/Bartvds>
|
||||
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||
|
||||
/// <reference types="node" />
|
||||
|
||||
export interface Options {
|
||||
recurse: boolean;
|
||||
}
|
||||
|
||||
export function parse(pattern: any): NodeJS.ReadWriteStream;
|
||||
export function parse(patterns: any[]): NodeJS.ReadWriteStream;
|
||||
|
||||
/**
|
||||
* Create a writable stream.
|
||||
* you may pass in custom open, close, and seperator strings. But, by default,
|
||||
* JSONStream.stringify() will create an array,
|
||||
* (with default options open='[\n', sep='\n,\n', close='\n]\n')
|
||||
*/
|
||||
export function stringify(): NodeJS.ReadWriteStream;
|
||||
|
||||
/** If you call JSONStream.stringify(false) the elements will only be seperated by a newline. */
|
||||
export function stringify(
|
||||
newlineOnly: NewlineOnlyIndicator,
|
||||
): NodeJS.ReadWriteStream;
|
||||
type NewlineOnlyIndicator = false;
|
||||
|
||||
/**
|
||||
* Create a writable stream.
|
||||
* you may pass in custom open, close, and seperator strings. But, by default,
|
||||
* JSONStream.stringify() will create an array,
|
||||
* (with default options open='[\n', sep='\n,\n', close='\n]\n')
|
||||
*/
|
||||
export function stringify(
|
||||
open: string,
|
||||
sep: string,
|
||||
close: string,
|
||||
): NodeJS.ReadWriteStream;
|
||||
|
||||
export function stringifyObject(): NodeJS.ReadWriteStream;
|
||||
export function stringifyObject(
|
||||
open: string,
|
||||
sep: string,
|
||||
close: string,
|
||||
): NodeJS.ReadWriteStream;
|
||||
}
|
Loading…
Reference in New Issue
Block a user