mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-18 21:27:51 +00:00
Support multicontainer local mode in resin push
Change-type: minor Signed-off-by: Cameron Diver <cameron@resin.io>
This commit is contained in:
parent
c5d4e30e24
commit
947f91d570
@ -159,7 +159,7 @@ environment variable (in the same standard URL format).
|
|||||||
|
|
||||||
- Push
|
- Push
|
||||||
|
|
||||||
- [push <application>](#push-application-)
|
- [push <applicationOrDevice>](#push-applicationordevice-)
|
||||||
|
|
||||||
- Settings
|
- Settings
|
||||||
|
|
||||||
@ -1249,19 +1249,29 @@ Docker host TLS key file
|
|||||||
|
|
||||||
# Push
|
# Push
|
||||||
|
|
||||||
## push <application>
|
## push <applicationOrDevice>
|
||||||
|
|
||||||
This command can be used to start a build on the remote
|
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 cloud builders, or a local mode resin device.
|
||||||
|
|
||||||
|
When building on the resin cloud 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
|
resin.io builder, and the build will proceed. This can be used as a drop-in
|
||||||
replacement for git push to deploy.
|
replacement for git push to deploy.
|
||||||
|
|
||||||
|
When building on a local mode device, the given source directory will be built on
|
||||||
|
device, and the resulting contianers will be run on the device. Logs will be
|
||||||
|
streamed back from the device as part of the same invocation.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
$ resin push myApp
|
$ resin push myApp
|
||||||
$ resin push myApp --source <source directory>
|
$ resin push myApp --source <source directory>
|
||||||
$ resin push myApp -s <source directory>
|
$ resin push myApp -s <source directory>
|
||||||
|
|
||||||
|
$ resin push 10.0.0.1
|
||||||
|
$ resin push 10.0.0.1 --source <source directory>
|
||||||
|
$ resin push 10.0.0.1 -s <source directory>
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
#### --source, -s <source>
|
#### --source, -s <source>
|
||||||
|
@ -15,8 +15,33 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CommandDefinition } from 'capitano';
|
import { CommandDefinition } from 'capitano';
|
||||||
import { ResinSDK } from 'resin-sdk';
|
|
||||||
import { stripIndent } from 'common-tags';
|
import { stripIndent } from 'common-tags';
|
||||||
|
import { ResinSDK } from 'resin-sdk';
|
||||||
|
|
||||||
|
import { BuildError } from '../utils/device/errors';
|
||||||
|
|
||||||
|
// An regex to detect an IP address, from https://www.regular-expressions.info/ip.html
|
||||||
|
const IP_REGEX = new RegExp(
|
||||||
|
/\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/,
|
||||||
|
);
|
||||||
|
|
||||||
|
enum BuildTarget {
|
||||||
|
Cloud,
|
||||||
|
Device,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBuildTarget(appOrDevice: string): BuildTarget | null {
|
||||||
|
// First try the application regex from the api
|
||||||
|
if (/^[a-zA-Z0-9_-]+$/.test(appOrDevice)) {
|
||||||
|
return BuildTarget.Cloud;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IP_REGEX.test(appOrDevice)) {
|
||||||
|
return BuildTarget.Device;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function getAppOwner(sdk: ResinSDK, appName: string) {
|
async function getAppOwner(sdk: ResinSDK, appName: string) {
|
||||||
const {
|
const {
|
||||||
@ -75,7 +100,7 @@ async function getAppOwner(sdk: ResinSDK, appName: string) {
|
|||||||
|
|
||||||
export const push: CommandDefinition<
|
export const push: CommandDefinition<
|
||||||
{
|
{
|
||||||
application: string;
|
applicationOrDevice: string;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: string;
|
source: string;
|
||||||
@ -83,19 +108,30 @@ export const push: CommandDefinition<
|
|||||||
nocache: boolean;
|
nocache: boolean;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
signature: 'push <application>',
|
signature: 'push <applicationOrDevice>',
|
||||||
description: 'Start a remote build on the resin.io cloud build servers',
|
description:
|
||||||
|
'Start a remote build on the resin.io cloud build servers or a local mode device',
|
||||||
help: stripIndent`
|
help: stripIndent`
|
||||||
This command can be used to start a build on the remote
|
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 cloud builders, or a local mode resin device.
|
||||||
|
|
||||||
|
When building on the resin cloud 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
|
resin.io builder, and the build will proceed. This can be used as a drop-in
|
||||||
replacement for git push to deploy.
|
replacement for git push to deploy.
|
||||||
|
|
||||||
|
When building on a local mode device, the given source directory will be built on
|
||||||
|
device, and the resulting containers will be run on the device. Logs will be
|
||||||
|
streamed back from the device as part of the same invocation.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
$ resin push myApp
|
$ resin push myApp
|
||||||
$ resin push myApp --source <source directory>
|
$ resin push myApp --source <source directory>
|
||||||
$ resin push myApp -s <source directory>
|
$ resin push myApp -s <source directory>
|
||||||
|
|
||||||
|
$ resin push 10.0.0.1
|
||||||
|
$ resin push 10.0.0.1 --source <source directory>
|
||||||
|
$ resin push 10.0.0.1 -s <source directory>
|
||||||
`,
|
`,
|
||||||
permission: 'user',
|
permission: 'user',
|
||||||
options: [
|
options: [
|
||||||
@ -123,11 +159,12 @@ export const push: CommandDefinition<
|
|||||||
const sdk = (await import('resin-sdk')).fromSharedOptions();
|
const sdk = (await import('resin-sdk')).fromSharedOptions();
|
||||||
const Bluebird = await import('bluebird');
|
const Bluebird = await import('bluebird');
|
||||||
const remote = await import('../utils/remote-build');
|
const remote = await import('../utils/remote-build');
|
||||||
|
const deviceDeploy = await import('../utils/device/deploy');
|
||||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||||
|
|
||||||
const app: string | null = params.application;
|
const appOrDevice: string | null = params.applicationOrDevice;
|
||||||
if (app == null) {
|
if (appOrDevice == null) {
|
||||||
exitWithExpectedError('You must specify an application');
|
exitWithExpectedError('You must specify an application or a device');
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = options.source || '.';
|
const source = options.source || '.';
|
||||||
@ -135,29 +172,58 @@ export const push: CommandDefinition<
|
|||||||
console.log(`[debug] Using ${source} as build source`);
|
console.log(`[debug] Using ${source} as build source`);
|
||||||
}
|
}
|
||||||
|
|
||||||
Bluebird.join(
|
const buildTarget = getBuildTarget(appOrDevice);
|
||||||
sdk.auth.getToken(),
|
switch (buildTarget) {
|
||||||
sdk.settings.get('resinUrl'),
|
case BuildTarget.Cloud:
|
||||||
getAppOwner(sdk, app),
|
const app = appOrDevice;
|
||||||
(token, baseUrl, owner) => {
|
Bluebird.join(
|
||||||
const opts = {
|
sdk.auth.getToken(),
|
||||||
emulated: options.emulated,
|
sdk.settings.get('resinUrl'),
|
||||||
nocache: options.nocache,
|
getAppOwner(sdk, app),
|
||||||
};
|
(token, baseUrl, owner) => {
|
||||||
const args = {
|
const opts = {
|
||||||
app,
|
emulated: options.emulated,
|
||||||
owner,
|
nocache: options.nocache,
|
||||||
source,
|
};
|
||||||
auth: token,
|
const args = {
|
||||||
baseUrl,
|
app,
|
||||||
sdk,
|
owner,
|
||||||
opts,
|
source,
|
||||||
};
|
auth: token,
|
||||||
|
baseUrl,
|
||||||
|
sdk,
|
||||||
|
opts,
|
||||||
|
};
|
||||||
|
|
||||||
return remote.startRemoteBuild(args);
|
return remote.startRemoteBuild(args);
|
||||||
},
|
},
|
||||||
)
|
).nodeify(done);
|
||||||
.catch(remote.RemoteBuildFailedError, exitWithExpectedError)
|
break;
|
||||||
.nodeify(done);
|
case BuildTarget.Device:
|
||||||
|
const device = appOrDevice;
|
||||||
|
// TODO: Support passing a different port
|
||||||
|
Bluebird.resolve(
|
||||||
|
deviceDeploy.deployToDevice({
|
||||||
|
source,
|
||||||
|
deviceHost: device,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(BuildError, e => {
|
||||||
|
exitWithExpectedError(e.toString());
|
||||||
|
})
|
||||||
|
.nodeify(done);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
exitWithExpectedError(
|
||||||
|
stripIndent`
|
||||||
|
Build target not recognised. Please provide either an application name or device address.
|
||||||
|
|
||||||
|
The only supported device addresses currently are IP addresses.
|
||||||
|
|
||||||
|
If you believe your build target should have been detected, and this is an error, please
|
||||||
|
create an issue.`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
170
lib/utils/device/api.ts
Normal file
170
lib/utils/device/api.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import * as Bluebird from 'bluebird';
|
||||||
|
import * as request from 'request';
|
||||||
|
import * as Stream from 'stream';
|
||||||
|
|
||||||
|
import Logger = require('../logger');
|
||||||
|
|
||||||
|
import * as ApiErrors from './errors';
|
||||||
|
|
||||||
|
export interface DeviceResponse {
|
||||||
|
[key: string]: any;
|
||||||
|
|
||||||
|
status: 'success' | 'failed';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceType: string;
|
||||||
|
arch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceEndpoints = {
|
||||||
|
setTargetState: 'v2/local/target-state',
|
||||||
|
getTargetState: 'v2/local/target-state',
|
||||||
|
getDeviceInformation: 'v2/local/device-info',
|
||||||
|
logs: 'v2/local/logs',
|
||||||
|
ping: 'ping',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DeviceAPI {
|
||||||
|
private deviceAddress: string;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private logger: Logger,
|
||||||
|
addr: string,
|
||||||
|
port: number = 48484,
|
||||||
|
) {
|
||||||
|
this.deviceAddress = `http://${addr}:${port}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either return nothing, or throw an error with the info
|
||||||
|
public async setTargetState(state: any): Promise<void> {
|
||||||
|
const url = this.getUrlForAction('setTargetState');
|
||||||
|
return DeviceAPI.promisifiedRequest(
|
||||||
|
request.post,
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
json: true,
|
||||||
|
body: state,
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTargetState(): Promise<any> {
|
||||||
|
const url = this.getUrlForAction('getTargetState');
|
||||||
|
|
||||||
|
return DeviceAPI.promisifiedRequest(
|
||||||
|
request.get,
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
).then(body => {
|
||||||
|
return body.state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDeviceInformation(): Promise<DeviceInfo> {
|
||||||
|
const url = this.getUrlForAction('getDeviceInformation');
|
||||||
|
|
||||||
|
return DeviceAPI.promisifiedRequest(
|
||||||
|
request.get,
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
).then(body => {
|
||||||
|
return body.info;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ping(): Promise<void> {
|
||||||
|
const url = this.getUrlForAction('ping');
|
||||||
|
|
||||||
|
return DeviceAPI.promisifiedRequest(
|
||||||
|
request.get,
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLogStream(): Bluebird<Stream.Readable> {
|
||||||
|
const url = this.getUrlForAction('logs');
|
||||||
|
|
||||||
|
// Don't use the promisified version here as we want to stream the output
|
||||||
|
return new Bluebird((resolve, reject) => {
|
||||||
|
const req = request.get(url);
|
||||||
|
|
||||||
|
req.on('error', reject).on('response', res => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(
|
||||||
|
new ApiErrors.DeviceAPIError(
|
||||||
|
'Non-200 response from log streaming endpoint',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUrlForAction(action: keyof typeof deviceEndpoints): string {
|
||||||
|
return `${this.deviceAddress}${deviceEndpoints[action]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper method for promisifying general (non-streaming) requests. Streaming
|
||||||
|
// requests should use a seperate setup
|
||||||
|
private static async promisifiedRequest<T>(
|
||||||
|
requestMethod: (
|
||||||
|
opts: T,
|
||||||
|
cb: (err?: any, res?: any, body?: any) => void,
|
||||||
|
) => void,
|
||||||
|
opts: T,
|
||||||
|
logger?: Logger,
|
||||||
|
): Promise<any> {
|
||||||
|
const Bluebird = await import('bluebird');
|
||||||
|
const _ = await import('lodash');
|
||||||
|
|
||||||
|
type ObjectWithUrl = { url?: string };
|
||||||
|
|
||||||
|
if (logger != null) {
|
||||||
|
let url: string | null = null;
|
||||||
|
if (_.isObject(opts) && (opts as ObjectWithUrl).url != null) {
|
||||||
|
// the `as string` shouldn't be necessary, but the type system
|
||||||
|
// is getting a little confused
|
||||||
|
url = (opts as ObjectWithUrl).url as string;
|
||||||
|
} else if (_.isString(opts)) {
|
||||||
|
url = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url != null) {
|
||||||
|
logger.logDebug(`Sending request to ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bluebird.fromCallback(
|
||||||
|
cb => {
|
||||||
|
return requestMethod(opts, cb);
|
||||||
|
},
|
||||||
|
{ multiArgs: true },
|
||||||
|
).then(([response, body]) => {
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
return body;
|
||||||
|
case 400:
|
||||||
|
throw new ApiErrors.BadRequestDeviceAPIError(body.message);
|
||||||
|
case 503:
|
||||||
|
throw new ApiErrors.ServiceUnavailableAPIError(body.message);
|
||||||
|
default:
|
||||||
|
throw new ApiErrors.DeviceAPIError(body.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceAPI;
|
284
lib/utils/device/deploy.ts
Normal file
284
lib/utils/device/deploy.ts
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import * as Bluebird from 'bluebird';
|
||||||
|
import * as Docker from 'dockerode';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import { Composition } from 'resin-compose-parse';
|
||||||
|
import { BuildTask, LocalImage } from 'resin-multibuild';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import Logger = require('../logger');
|
||||||
|
import { displayBuildLog } from './logs';
|
||||||
|
|
||||||
|
import { DeviceInfo } from './api';
|
||||||
|
import * as LocalPushErrors from './errors';
|
||||||
|
|
||||||
|
// Define the logger here so the debug output
|
||||||
|
// can be used everywhere
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
export interface DeviceDeployOptions {
|
||||||
|
source: string;
|
||||||
|
deviceHost: string;
|
||||||
|
devicePort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSource(source: string): Promise<boolean> {
|
||||||
|
const { fs } = await import('mz');
|
||||||
|
return (await fs.exists(source)) && (await fs.stat(source)).isDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
|
||||||
|
const { loadProject, tarDirectory } = await import('../compose');
|
||||||
|
const { exitWithExpectedError } = await import('../patterns');
|
||||||
|
|
||||||
|
const { DeviceAPI } = await import('./api');
|
||||||
|
const { displayDeviceLogs } = await import('./logs');
|
||||||
|
|
||||||
|
if (!(await checkSource(opts.source))) {
|
||||||
|
exitWithExpectedError(`Could not access source directory: ${opts.source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = new DeviceAPI(logger, opts.deviceHost);
|
||||||
|
|
||||||
|
// TODO: Before merge, replace this with the supervisor version endpoint, to
|
||||||
|
// ensure we're working with a supervisor version that supports the stuff we need
|
||||||
|
await api.ping();
|
||||||
|
|
||||||
|
logger.logInfo(`Starting build on device ${opts.deviceHost}`);
|
||||||
|
|
||||||
|
const project = await loadProject(logger, opts.source, 'local');
|
||||||
|
|
||||||
|
// Attempt to attach to the device's docker daemon
|
||||||
|
const docker = connectToDocker(
|
||||||
|
opts.deviceHost,
|
||||||
|
opts.devicePort != null ? opts.devicePort : 2375,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tarStream = await tarDirectory(opts.source);
|
||||||
|
|
||||||
|
// Try to detect the device information
|
||||||
|
const deviceInfo = await api.getDeviceInformation();
|
||||||
|
|
||||||
|
await performBuilds(
|
||||||
|
project.composition,
|
||||||
|
tarStream,
|
||||||
|
docker,
|
||||||
|
deviceInfo,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.logDebug('Setting device state...');
|
||||||
|
// Now set the target state on the device
|
||||||
|
|
||||||
|
const currentTargetState = await api.getTargetState();
|
||||||
|
|
||||||
|
const targetState = generateTargetState(
|
||||||
|
currentTargetState,
|
||||||
|
project.composition,
|
||||||
|
);
|
||||||
|
logger.logDebug(`Sending target state: ${JSON.stringify(targetState)}`);
|
||||||
|
|
||||||
|
await api.setTargetState(targetState);
|
||||||
|
|
||||||
|
// Print an empty newline to seperate the build output
|
||||||
|
// from the device output
|
||||||
|
console.log();
|
||||||
|
logger.logInfo('Streaming device logs...');
|
||||||
|
// Now all we need to do is stream back the logs
|
||||||
|
const logStream = await api.getLogStream();
|
||||||
|
|
||||||
|
await displayDeviceLogs(logStream, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectToDocker(host: string, port: number): Docker {
|
||||||
|
return new Docker({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
Promise: Bluebird as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performBuilds(
|
||||||
|
composition: Composition,
|
||||||
|
tarStream: Readable,
|
||||||
|
docker: Docker,
|
||||||
|
deviceInfo: DeviceInfo,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
const multibuild = await import('resin-multibuild');
|
||||||
|
|
||||||
|
const buildTasks = await multibuild.splitBuildStream(composition, tarStream);
|
||||||
|
|
||||||
|
logger.logDebug('Found build tasks:');
|
||||||
|
_.each(buildTasks, task => {
|
||||||
|
let infoStr: string;
|
||||||
|
if (task.external) {
|
||||||
|
infoStr = `image pull [${task.imageName}]`;
|
||||||
|
} else {
|
||||||
|
infoStr = `build [${task.context}]`;
|
||||||
|
}
|
||||||
|
logger.logDebug(` ${task.serviceName}: ${infoStr}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.logDebug(
|
||||||
|
`Resolving services with [${deviceInfo.deviceType}|${deviceInfo.arch}]`,
|
||||||
|
);
|
||||||
|
await multibuild.performResolution(
|
||||||
|
buildTasks,
|
||||||
|
deviceInfo.arch,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.logDebug('Found project types:');
|
||||||
|
_.each(buildTasks, task => {
|
||||||
|
if (!task.external) {
|
||||||
|
logger.logDebug(` ${task.serviceName}: ${task.projectType}`);
|
||||||
|
} else {
|
||||||
|
logger.logDebug(` ${task.serviceName}: External image`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.logDebug('Probing remote daemon for cache images');
|
||||||
|
await assignDockerBuildOpts(docker, buildTasks);
|
||||||
|
|
||||||
|
logger.logDebug('Starting builds...');
|
||||||
|
await assignOutputHandlers(buildTasks, logger);
|
||||||
|
const localImages = await multibuild.performBuilds(buildTasks, docker);
|
||||||
|
|
||||||
|
// Check for failures
|
||||||
|
await inspectBuildResults(localImages);
|
||||||
|
|
||||||
|
// Now tag any external images with the correct name that they should be,
|
||||||
|
// as this won't be done by resin-multibuild
|
||||||
|
await Bluebird.map(localImages, async localImage => {
|
||||||
|
if (localImage.external) {
|
||||||
|
// We can be sure that localImage.name is set here, because of the failure code above
|
||||||
|
const image = docker.getImage(localImage.name!);
|
||||||
|
await image.tag({
|
||||||
|
repo: generateImageName(localImage.serviceName),
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
await image.remove({ force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignOutputHandlers(buildTasks: BuildTask[], logger: Logger) {
|
||||||
|
_.each(buildTasks, task => {
|
||||||
|
if (task.external) {
|
||||||
|
task.progressHook = progressObj => {
|
||||||
|
displayBuildLog(
|
||||||
|
{ serviceName: task.serviceName, message: progressObj.progress },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
task.streamHook = stream => {
|
||||||
|
stream.on('data', (buf: Buffer) => {
|
||||||
|
const str = buf.toString().trimRight();
|
||||||
|
if (str !== '') {
|
||||||
|
displayBuildLog(
|
||||||
|
{ serviceName: task.serviceName, message: str },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeviceDockerImages(docker: Docker): Promise<string[]> {
|
||||||
|
const images = await docker.listImages();
|
||||||
|
|
||||||
|
return _.map(images, 'Id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutates buildTasks
|
||||||
|
async function assignDockerBuildOpts(
|
||||||
|
docker: Docker,
|
||||||
|
buildTasks: BuildTask[],
|
||||||
|
): Promise<void> {
|
||||||
|
// Get all of the images on the remote docker daemon, so
|
||||||
|
// that we can use all of them for cache
|
||||||
|
const images = await getDeviceDockerImages(docker);
|
||||||
|
|
||||||
|
logger.logDebug(`Using ${images.length} on-device images for cache...`);
|
||||||
|
|
||||||
|
_.each(buildTasks, (task: BuildTask) => {
|
||||||
|
task.dockerOpts = {
|
||||||
|
cachefrom: images,
|
||||||
|
labels: {
|
||||||
|
'io.resin.local.image': '1',
|
||||||
|
'io.resin.local.service': task.serviceName,
|
||||||
|
},
|
||||||
|
t: generateImageName(task.serviceName),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateImageName(serviceName: string): string {
|
||||||
|
return `local_image_${serviceName}:latest`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTargetState(
|
||||||
|
currentTargetState: any,
|
||||||
|
composition: Composition,
|
||||||
|
): any {
|
||||||
|
const services: { [serviceId: string]: any } = {};
|
||||||
|
let idx = 1;
|
||||||
|
_.each(composition.services, (opts, name) => {
|
||||||
|
// Get rid of any build specific stuff
|
||||||
|
opts = _.cloneDeep(opts);
|
||||||
|
delete opts.build;
|
||||||
|
delete opts.image;
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
environment: {},
|
||||||
|
labels: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
services[idx] = _.merge(defaults, opts, {
|
||||||
|
imageId: idx,
|
||||||
|
serviceName: name,
|
||||||
|
serviceId: idx,
|
||||||
|
image: generateImageName(name),
|
||||||
|
running: true,
|
||||||
|
});
|
||||||
|
idx += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetState = _.cloneDeep(currentTargetState);
|
||||||
|
delete targetState.local.apps;
|
||||||
|
|
||||||
|
targetState.local.apps = {
|
||||||
|
1: {
|
||||||
|
name: 'localapp',
|
||||||
|
commit: 'localcommit',
|
||||||
|
releaseId: '1',
|
||||||
|
services,
|
||||||
|
volumes: composition.volumes || {},
|
||||||
|
networks: composition.networks || {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return targetState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectBuildResults(images: LocalImage[]): Promise<void> {
|
||||||
|
const { exitWithExpectedError } = await import('../patterns');
|
||||||
|
|
||||||
|
const failures: LocalPushErrors.BuildFailure[] = [];
|
||||||
|
|
||||||
|
_.each(images, image => {
|
||||||
|
if (!image.successful) {
|
||||||
|
failures.push({
|
||||||
|
error: image.error!,
|
||||||
|
serviceName: image.serviceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
exitWithExpectedError(new LocalPushErrors.BuildError(failures));
|
||||||
|
}
|
||||||
|
}
|
30
lib/utils/device/errors.ts
Normal file
30
lib/utils/device/errors.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as _ from 'lodash';
|
||||||
|
import { TypedError } from 'typed-error';
|
||||||
|
|
||||||
|
export interface BuildFailure {
|
||||||
|
error: Error;
|
||||||
|
serviceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BuildError extends TypedError {
|
||||||
|
private failures: BuildFailure[];
|
||||||
|
|
||||||
|
public constructor(failures: BuildFailure[]) {
|
||||||
|
super('Build error');
|
||||||
|
|
||||||
|
this.failures = failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
let str = 'Some services failed to build:\n';
|
||||||
|
_.each(this.failures, failure => {
|
||||||
|
str += `\t${failure.serviceName}: ${failure.error.message}\n`;
|
||||||
|
});
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceAPIError extends TypedError {}
|
||||||
|
|
||||||
|
export class BadRequestDeviceAPIError extends DeviceAPIError {}
|
||||||
|
export class ServiceUnavailableAPIError extends DeviceAPIError {}
|
83
lib/utils/device/logs.ts
Normal file
83
lib/utils/device/logs.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import * as Bluebird from 'bluebird';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import ColorHash = require('color-hash');
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import Logger = require('../logger');
|
||||||
|
|
||||||
|
interface Log {
|
||||||
|
message: string;
|
||||||
|
timestamp?: number;
|
||||||
|
serviceName?: string;
|
||||||
|
|
||||||
|
// There's also a serviceId and imageId, but they're
|
||||||
|
// meaningless in local mode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildLog {
|
||||||
|
serviceName: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display logs from a device logging stream. This function will return
|
||||||
|
* when the log stream ends.
|
||||||
|
*
|
||||||
|
* @param logs A stream which produces newline seperated log objects
|
||||||
|
*/
|
||||||
|
export function displayDeviceLogs(
|
||||||
|
logs: Readable,
|
||||||
|
logger: Logger,
|
||||||
|
): Bluebird<void> {
|
||||||
|
return new Bluebird((resolve, reject) => {
|
||||||
|
logs.on('data', log => {
|
||||||
|
displayLogLine(log, logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
logs.on('error', reject);
|
||||||
|
logs.on('end', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayBuildLog(log: BuildLog, logger: Logger): void {
|
||||||
|
const toPrint = `${getServiceColourFn(log.serviceName)(
|
||||||
|
`[${log.serviceName}]`,
|
||||||
|
)} ${log.message}`;
|
||||||
|
logger.logBuild(toPrint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutates serviceColours
|
||||||
|
function displayLogLine(log: string | Buffer, logger: Logger): void {
|
||||||
|
try {
|
||||||
|
const obj: Log = JSON.parse(log.toString());
|
||||||
|
|
||||||
|
let toPrint: string;
|
||||||
|
if (obj.timestamp != null) {
|
||||||
|
toPrint = `[${new Date(obj.timestamp).toLocaleString()}]`;
|
||||||
|
} else {
|
||||||
|
toPrint = `[${new Date().toLocaleString()}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.serviceName != null) {
|
||||||
|
const colourFn = getServiceColourFn(obj.serviceName);
|
||||||
|
|
||||||
|
toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toPrint += ` ${obj.message}`;
|
||||||
|
|
||||||
|
logger.logLogs(toPrint);
|
||||||
|
} catch (e) {
|
||||||
|
logger.logDebug(`Dropping device log due to failed parsing: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getServiceColourFn = _.memoize(_getServiceColourFn);
|
||||||
|
|
||||||
|
const colorHash = new ColorHash();
|
||||||
|
function _getServiceColourFn(serviceName: string): (msg: string) => string {
|
||||||
|
const [r, g, b] = colorHash.rgb(serviceName);
|
||||||
|
|
||||||
|
return chalk.rgb(r, g, b);
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { EOL as eol } from 'os';
|
|
||||||
import _ = require('lodash');
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import _ = require('lodash');
|
||||||
|
import { EOL as eol } from 'os';
|
||||||
import { StreamLogger } from 'resin-stream-logger';
|
import { StreamLogger } from 'resin-stream-logger';
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
|
@ -229,7 +229,9 @@ export function inferOrSelectDevice(preferredUuid: string) {
|
|||||||
throw new Error("You don't have any devices online");
|
throw new Error("You don't have any devices online");
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultUuid = _.map(onlineDevices, 'uuid').includes(preferredUuid)
|
const defaultUuid = _(onlineDevices)
|
||||||
|
.map('uuid')
|
||||||
|
.includes(preferredUuid)
|
||||||
? preferredUuid
|
? preferredUuid
|
||||||
: onlineDevices[0].uuid;
|
: onlineDevices[0].uuid;
|
||||||
|
|
||||||
|
13
package.json
13
package.json
@ -43,7 +43,7 @@
|
|||||||
"test:fast": "npm run build:fast && gulp test",
|
"test:fast": "npm run build:fast && gulp test",
|
||||||
"ci": "npm run test && catch-uncommitted",
|
"ci": "npm run test && catch-uncommitted",
|
||||||
"watch": "gulp watch",
|
"watch": "gulp watch",
|
||||||
"prettify": "prettier --write \"{lib,tests,automation,typings}/**/*.ts\"",
|
"prettify": "prettier --write \"{lib,tests,automation,typings}/**/*.ts\" --config ./node_modules/resin-lint/config/.prettierrc",
|
||||||
"lint": "resin-lint lib/ tests/ && resin-lint --typescript automation/ lib/ typings/ tests/",
|
"lint": "resin-lint lib/ tests/ && resin-lint --typescript automation/ lib/ typings/ tests/",
|
||||||
"prepublish": "require-npm4-to-publish",
|
"prepublish": "require-npm4-to-publish",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
@ -80,10 +80,10 @@
|
|||||||
"gulp-shell": "^0.5.2",
|
"gulp-shell": "^0.5.2",
|
||||||
"mochainon": "^2.0.0",
|
"mochainon": "^2.0.0",
|
||||||
"pkg": "^4.3.0-beta.1",
|
"pkg": "^4.3.0-beta.1",
|
||||||
"prettier": "1.13.5",
|
"prettier": "^1.14.2",
|
||||||
"publish-release": "^1.3.3",
|
"publish-release": "^1.3.3",
|
||||||
"require-npm4-to-publish": "^1.0.0",
|
"require-npm4-to-publish": "^1.0.0",
|
||||||
"resin-lint": "^1.5.0",
|
"resin-lint": "^2.0.0",
|
||||||
"rewire": "^3.0.2",
|
"rewire": "^3.0.2",
|
||||||
"ts-node": "^4.0.1",
|
"ts-node": "^4.0.1",
|
||||||
"typescript": "2.8.1"
|
"typescript": "2.8.1"
|
||||||
@ -104,6 +104,7 @@
|
|||||||
"chalk": "^2.3.0",
|
"chalk": "^2.3.0",
|
||||||
"cli-truncate": "^1.1.0",
|
"cli-truncate": "^1.1.0",
|
||||||
"coffeescript": "^1.12.6",
|
"coffeescript": "^1.12.6",
|
||||||
|
"color-hash": "^1.0.3",
|
||||||
"columnify": "^1.5.2",
|
"columnify": "^1.5.2",
|
||||||
"common-tags": "^1.7.2",
|
"common-tags": "^1.7.2",
|
||||||
"denymount": "^2.2.0",
|
"denymount": "^2.2.0",
|
||||||
@ -139,17 +140,17 @@
|
|||||||
"raven": "^2.5.0",
|
"raven": "^2.5.0",
|
||||||
"reconfix": "^0.1.0",
|
"reconfix": "^0.1.0",
|
||||||
"request": "^2.81.0",
|
"request": "^2.81.0",
|
||||||
"resin-bundle-resolve": "^0.5.3",
|
"resin-bundle-resolve": "^0.6.0",
|
||||||
"resin-cli-form": "^2.0.0",
|
"resin-cli-form": "^2.0.0",
|
||||||
"resin-cli-visuals": "^1.4.0",
|
"resin-cli-visuals": "^1.4.0",
|
||||||
"resin-compose-parse": "^1.10.2",
|
"resin-compose-parse": "^2.0.0",
|
||||||
"resin-config-json": "^1.0.0",
|
"resin-config-json": "^1.0.0",
|
||||||
"resin-device-config": "^4.0.0",
|
"resin-device-config": "^4.0.0",
|
||||||
"resin-device-init": "^4.0.0",
|
"resin-device-init": "^4.0.0",
|
||||||
"resin-doodles": "0.0.1",
|
"resin-doodles": "0.0.1",
|
||||||
"resin-image-fs": "^5.0.2",
|
"resin-image-fs": "^5.0.2",
|
||||||
"resin-image-manager": "^5.0.0",
|
"resin-image-manager": "^5.0.0",
|
||||||
"resin-multibuild": "^0.5.1",
|
"resin-multibuild": "^0.9.0",
|
||||||
"resin-preload": "^7.0.0",
|
"resin-preload": "^7.0.0",
|
||||||
"resin-release": "^1.2.0",
|
"resin-release": "^1.2.0",
|
||||||
"resin-sdk": "10.0.0-beta2",
|
"resin-sdk": "10.0.0-beta2",
|
||||||
|
12
typings/color-hash.d.ts
vendored
Normal file
12
typings/color-hash.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
declare module 'color-hash' {
|
||||||
|
interface Hasher {
|
||||||
|
hex(text: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ColorHash {
|
||||||
|
hex(text: string): string;
|
||||||
|
rgb(text: string): [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export = ColorHash;
|
||||||
|
}
|
13
typings/dockerfile-template.d.ts
vendored
Normal file
13
typings/dockerfile-template.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
declare module 'dockerfile-template' {
|
||||||
|
/**
|
||||||
|
* Variables which define what will be replaced, and what they will be replaced with.
|
||||||
|
*/
|
||||||
|
export interface TemplateVariables {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function process(
|
||||||
|
content: string,
|
||||||
|
variables: TemplateVariables,
|
||||||
|
): string;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user