/** * @license * Copyright 2017-2021 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 { getVisuals } from './lazy'; import { promisify } from 'util'; import type * as Dockerode from 'dockerode'; import type Logger = require('./logger'); import type { Request } from 'request'; const getBuilderPushEndpoint = function ( baseUrl: string, owner: string, app: string, ) { const querystring = require('querystring') as typeof import('querystring'); const args = querystring.stringify({ owner, app }); return `https://builder.${baseUrl}/v1/push?${args}`; }; const getBuilderLogPushEndpoint = function ( baseUrl: string, buildId: number, owner: string, app: string, ) { const querystring = require('querystring') as typeof import('querystring'); const args = querystring.stringify({ owner, app, buildId }); return `https://builder.${baseUrl}/v1/pushLogs?${args}`; }; /** * @param {import('dockerode')} docker * @param {string} imageId * @param {string} bufferFile */ const bufferImage = function ( docker: Dockerode, imageId: string, bufferFile: string, ): Promise { const streamUtils = require('./streams') as typeof import('./streams'); const image = docker.getImage(imageId); const sizePromise = image.inspect().then((img) => img.Size); return Promise.all([image.get(), sizePromise]).then( ([imageStream, imageSize]) => streamUtils .buffer(imageStream, bufferFile) .then((bufferedStream: NodeJS.ReadableStream & { length?: number }) => { bufferedStream.length = imageSize; return bufferedStream as NodeJS.ReadableStream & { length: number }; }), ); }; const showPushProgress = function (message: string) { const visuals = getVisuals(); const progressBar = new visuals.Progress(message); progressBar.update({ percentage: 0 }); return progressBar; }; const uploadToPromise = (uploadRequest: Request, logger: Logger) => new Promise<{ buildId: number }>(function (resolve, reject) { uploadRequest.on('error', reject).on('data', function handleMessage(data) { let obj; data = data.toString(); logger.logDebug(`Received data: ${data}`); try { obj = JSON.parse(data); } catch (e) { logger.logError('Error parsing reply from remote side'); reject(e); return; } switch (obj.type) { case 'error': reject(new Error(`Remote error: ${obj.error}`)); break; case 'success': resolve(obj); break; case 'status': logger.logInfo(obj.message); break; default: reject(new Error(`Received unexpected reply from remote: ${data}`)); } }); }); /** * @returns {Promise<{ buildId: number }>} */ const uploadImage = function ( imageStream: NodeJS.ReadableStream & { length: number }, token: string, username: string, url: string, appName: string, logger: Logger, ): Promise<{ buildId: number }> { const request = require('request') as typeof import('request'); const progressStream = require('progress-stream') as typeof import('progress-stream'); const zlib = require('zlib') as typeof import('zlib'); // Need to strip off the newline const progressMessage = logger .formatMessage('info', 'Uploading') .slice(0, -1); const progressBar = showPushProgress(progressMessage); const streamWithProgress = imageStream.pipe( progressStream( { time: 500, length: imageStream.length, }, ({ percentage, eta }) => progressBar.update({ percentage: Math.min(percentage, 100), eta, }), ), ); const uploadRequest = request.post({ url: getBuilderPushEndpoint(url, username, appName), headers: { 'Content-Encoding': 'gzip', }, auth: { bearer: token, }, body: streamWithProgress.pipe( zlib.createGzip({ level: 6, }), ), }); return uploadToPromise(uploadRequest, logger); }; const uploadLogs = function ( logs: string, token: string, url: string, buildId: number, username: string, appName: string, ) { const request = require('request') as typeof import('request'); return request.post({ json: true, url: getBuilderLogPushEndpoint(url, buildId, username, appName), auth: { bearer: token, }, body: Buffer.from(logs), }); }; /** * - appName: the name of the app to deploy to * - imageName: the name of the image to deploy * - buildLogs: a string with build output */ export const deployLegacy = async function ( docker: Dockerode, logger: Logger, token: string, username: string, url: string, opts: { appName: string; imageName: string; buildLogs: string; shouldUploadLogs: boolean; }, ): Promise { const tmp = require('tmp') as typeof import('tmp'); const tmpNameAsync = promisify(tmp.tmpName); // Ensure the tmp files gets deleted tmp.setGracefulCleanup(); const { appName, imageName, buildLogs, shouldUploadLogs } = opts; const logs = buildLogs; const bufferFile = await tmpNameAsync(); logger.logInfo('Initializing deploy...'); const { buildId } = await bufferImage(docker, imageName, bufferFile) .then((stream) => uploadImage(stream, token, username, url, appName, logger), ) .finally(() => // If the file was never written to (for instance because an error // has occured before any data was written) this call will throw an // ugly error, just suppress it (require('fs') as typeof import('fs')).promises .unlink(bufferFile) .catch(() => undefined), ); if (shouldUploadLogs) { logger.logInfo('Uploading logs...'); const args = await Promise.all([ logs, token, url, buildId, username, appName, ]); await uploadLogs(...args); } return buildId; };