Auto-merge for PR #868 via VersionBot

Add push command which starts a build on remote resin servers
This commit is contained in:
resin-io-versionbot[bot] 2018-05-10 12:44:43 +00:00 committed by GitHub
commit 22e74983b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 445 additions and 3 deletions

View File

@ -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! automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/). 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 ## v7.3.8 - 2018-05-03
* Catch require errors and provide helpful instructions #874 [Tim Perry] * Catch require errors and provide helpful instructions #874 [Tim Perry]

View File

@ -37,3 +37,4 @@ module.exports =
deploy: require('./deploy') deploy: require('./deploy')
util: require('./util') util: require('./util')
preload: require('./preload') preload: require('./preload')
push: require('./push')

142
lib/actions/push.ts Normal file
View 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);
},
};

View File

@ -212,6 +212,9 @@ capitano.command(actions.internal.osInit)
capitano.command(actions.build) capitano.command(actions.build)
capitano.command(actions.deploy) capitano.command(actions.deploy)
#------------ Push/remote builds -------
capitano.command(actions.push.push)
update.notify() update.notify()
cli = capitano.parse(process.argv) cli = capitano.parse(process.argv)

View File

@ -102,7 +102,7 @@ toPosixPath = (systemPath) ->
path = require('path') path = require('path')
systemPath.replace(new RegExp('\\' + path.sep, 'g'), '/') systemPath.replace(new RegExp('\\' + path.sep, 'g'), '/')
tarDirectory = (dir) -> exports.tarDirectory = tarDirectory = (dir) ->
tar = require('tar-stream') tar = require('tar-stream')
klaw = require('klaw') klaw = require('klaw')
path = require('path') path = require('path')

3
lib/utils/compose.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import * as Stream from 'stream';
export function tarDirectory(source: string): Promise<Stream.Readable>;

View File

@ -23,6 +23,11 @@ import chalk from 'chalk';
import validation = require('./validation'); import validation = require('./validation');
import messages = require('./messages'); import messages = require('./messages');
export interface ListSelectionEntry {
name: string;
extra: any;
}
export function authenticate(options: {}): Promise<void> { export function authenticate(options: {}): Promise<void> {
return form return form
.run( .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) { export function printErrorMessage(message: string) {
console.error(chalk.red(message)); console.error(chalk.red(message));
console.error(chalk.red(`\n${messages.getHelp}\n`)); console.error(chalk.red(`\n${messages.getHelp}\n`));

215
lib/utils/remote-build.ts Normal file
View 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;
}

View File

@ -1,6 +1,6 @@
{ {
"name": "resin-cli", "name": "resin-cli",
"version": "7.3.8", "version": "7.4.0",
"description": "The official resin.io CLI tool", "description": "The official resin.io CLI tool",
"main": "./build/actions/index.js", "main": "./build/actions/index.js",
"homepage": "https://github.com/resin-io/resin-cli", "homepage": "https://github.com/resin-io/resin-cli",
@ -89,6 +89,7 @@
}, },
"dependencies": { "dependencies": {
"@resin.io/valid-email": "^0.1.0", "@resin.io/valid-email": "^0.1.0",
"@types/stream-to-promise": "^2.2.0",
"ansi-escapes": "^2.0.0", "ansi-escapes": "^2.0.0",
"any-promise": "^1.3.0", "any-promise": "^1.3.0",
"archiver": "^2.1.0", "archiver": "^2.1.0",
@ -117,6 +118,7 @@
"inquirer": "^3.1.1", "inquirer": "^3.1.1",
"is-root": "^1.0.0", "is-root": "^1.0.0",
"js-yaml": "^3.10.0", "js-yaml": "^3.10.0",
"JSONStream": "^1.0.3",
"klaw": "^1.3.1", "klaw": "^1.3.1",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"mixpanel": "^0.4.0", "mixpanel": "^0.4.0",
@ -146,7 +148,7 @@
"resin-multibuild": "^0.5.1", "resin-multibuild": "^0.5.1",
"resin-preload": "^6.2.0", "resin-preload": "^6.2.0",
"resin-release": "^1.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-sdk-preconfigured": "^6.9.0",
"resin-settings-client": "^3.6.1", "resin-settings-client": "^3.6.1",
"resin-stream-logger": "^0.1.0", "resin-stream-logger": "^0.1.0",

53
typings/JSONStream.d.ts vendored Normal file
View 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;
}