mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-27 09:12:29 +00:00
b30075a18b
Change-type: patch
298 lines
6.9 KiB
TypeScript
298 lines
6.9 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2019 Balena Ltd.
|
|
*
|
|
* 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 _ from 'lodash';
|
|
import type { NodeJSSocketWithFileDescriptor } from 'net-keepalive';
|
|
import * as os from 'os';
|
|
import * as request from 'request';
|
|
import type * as Stream from 'stream';
|
|
|
|
import { retry } from '../helpers';
|
|
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;
|
|
}
|
|
|
|
export interface Status {
|
|
appState: 'applied' | 'applying';
|
|
overallDownloadProgress: null | number;
|
|
containers: Array<{
|
|
status: string;
|
|
serviceName: string;
|
|
appId: number;
|
|
imageId: number;
|
|
serviceId: number;
|
|
containerId: string;
|
|
createdAt: string;
|
|
}>;
|
|
images: Array<{
|
|
name: string;
|
|
appId: number;
|
|
serviceName: string;
|
|
imageId: number;
|
|
dockerImageId: string;
|
|
status: string;
|
|
downloadProgress: null | number;
|
|
}>;
|
|
}
|
|
|
|
const deviceEndpoints = {
|
|
setTargetState: 'v2/local/target-state',
|
|
getTargetState: 'v2/local/target-state',
|
|
getDeviceInformation: 'v2/local/device-info',
|
|
logs: 'v2/local/logs',
|
|
ping: 'ping',
|
|
version: 'v2/version',
|
|
status: 'v2/state/status',
|
|
containerId: 'v2/containerId',
|
|
};
|
|
|
|
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(
|
|
{
|
|
method: 'POST',
|
|
url,
|
|
json: true,
|
|
body: state,
|
|
},
|
|
this.logger,
|
|
);
|
|
}
|
|
|
|
public async getTargetState(): Promise<any> {
|
|
const url = this.getUrlForAction('getTargetState');
|
|
|
|
return DeviceAPI.promisifiedRequest(
|
|
{
|
|
method: 'GET',
|
|
url,
|
|
json: true,
|
|
},
|
|
this.logger,
|
|
).then((body) => {
|
|
return body.state;
|
|
});
|
|
}
|
|
|
|
public async getDeviceInformation(): Promise<DeviceInfo> {
|
|
const url = this.getUrlForAction('getDeviceInformation');
|
|
|
|
return DeviceAPI.promisifiedRequest(
|
|
{
|
|
method: 'GET',
|
|
url,
|
|
json: true,
|
|
},
|
|
this.logger,
|
|
).then((body) => {
|
|
return body.info;
|
|
});
|
|
}
|
|
|
|
public async getContainerId(serviceName: string): Promise<string> {
|
|
const url = this.getUrlForAction('containerId');
|
|
|
|
const body = await DeviceAPI.promisifiedRequest(
|
|
{
|
|
method: 'GET',
|
|
url,
|
|
json: true,
|
|
qs: {
|
|
serviceName,
|
|
},
|
|
},
|
|
this.logger,
|
|
);
|
|
|
|
if (body.status !== 'success') {
|
|
throw new ApiErrors.DeviceAPIError(
|
|
'Non-successful response from supervisor containerId endpoint',
|
|
);
|
|
}
|
|
return body.containerId;
|
|
}
|
|
|
|
public async ping(): Promise<void> {
|
|
const url = this.getUrlForAction('ping');
|
|
|
|
return DeviceAPI.promisifiedRequest(
|
|
{
|
|
method: 'GET',
|
|
url,
|
|
},
|
|
this.logger,
|
|
);
|
|
}
|
|
|
|
public getVersion(): Promise<string> {
|
|
const url = this.getUrlForAction('version');
|
|
|
|
return DeviceAPI.promisifiedRequest({
|
|
method: 'GET',
|
|
url,
|
|
json: true,
|
|
}).then((body) => {
|
|
if (body.status !== 'success') {
|
|
throw new ApiErrors.DeviceAPIError(
|
|
'Non-successful response from supervisor version endpoint',
|
|
);
|
|
}
|
|
|
|
return body.version;
|
|
});
|
|
}
|
|
|
|
public getStatus(): Promise<Status> {
|
|
const url = this.getUrlForAction('status');
|
|
|
|
return DeviceAPI.promisifiedRequest({
|
|
method: 'GET',
|
|
url,
|
|
json: true,
|
|
}).then((body) => {
|
|
if (body.status !== 'success') {
|
|
throw new ApiErrors.DeviceAPIError(
|
|
'Non-successful response from supervisor status endpoint',
|
|
);
|
|
}
|
|
|
|
return _.omit(body, 'status') as Status;
|
|
});
|
|
}
|
|
|
|
public getLogStream(): Promise<Stream.Readable> {
|
|
const url = this.getUrlForAction('logs');
|
|
|
|
// Don't use the promisified version here as we want to stream the output
|
|
return new Promise((resolve, reject) => {
|
|
const req = request.get(url);
|
|
|
|
req.on('error', reject).on('response', async (res) => {
|
|
if (res.statusCode !== 200) {
|
|
reject(
|
|
new ApiErrors.DeviceAPIError(
|
|
'Non-200 response from log streaming endpoint',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
res.socket.setKeepAlive(true, 1000);
|
|
if (os.platform() !== 'win32') {
|
|
const NetKeepalive = await import('net-keepalive');
|
|
// Certain versions of typescript won't convert
|
|
// this automatically
|
|
const sock = res.socket as any as NodeJSSocketWithFileDescriptor;
|
|
// We send a tcp keepalive probe once every 5 seconds
|
|
NetKeepalive.setKeepAliveInterval(sock, 5000);
|
|
// After 5 failed probes, the connection is marked as
|
|
// closed
|
|
NetKeepalive.setKeepAliveProbes(sock, 5);
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
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 extends Parameters<typeof request>[0],
|
|
>(opts: T, logger?: Logger): Promise<any> {
|
|
interface 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 (typeof opts === 'string') {
|
|
url = opts;
|
|
}
|
|
|
|
if (url != null) {
|
|
logger.logDebug(`Sending request to ${url}`);
|
|
}
|
|
}
|
|
|
|
const doRequest = async () => {
|
|
return await new Promise((resolve, reject) => {
|
|
return request(opts, (err, response, body) => {
|
|
if (err) {
|
|
return reject(err);
|
|
}
|
|
switch (response.statusCode) {
|
|
case 200:
|
|
return resolve(body);
|
|
case 400:
|
|
return reject(
|
|
new ApiErrors.BadRequestDeviceAPIError(body.message),
|
|
);
|
|
case 503:
|
|
return reject(
|
|
new ApiErrors.ServiceUnavailableAPIError(body.message),
|
|
);
|
|
default:
|
|
return reject(new ApiErrors.DeviceAPIError(body.message));
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
return await retry({
|
|
func: doRequest,
|
|
initialDelayMs: 2000,
|
|
maxAttempts: 6,
|
|
label: `Supervisor API (${opts.method} ${(opts as any).url})`,
|
|
});
|
|
}
|
|
}
|
|
|
|
export default DeviceAPI;
|