diff --git a/app-backend/src/api/ApiStatusCodes.js b/app-backend/src/api/ApiStatusCodes.js index 8b860ae..a6942eb 100644 --- a/app-backend/src/api/ApiStatusCodes.js +++ b/app-backend/src/api/ApiStatusCodes.js @@ -10,6 +10,8 @@ let apiStatusCode = { STATUS_OK: 100, + STATUS_OK_DEPLOY_STARTED: 101, + STATUS_ERROR_GENERIC: 1000, STATUS_ERROR_CAPTAIN_NOT_INITIALIZED: 1001, diff --git a/app-backend/src/docker/DockerApi.js b/app-backend/src/docker/DockerApi.js index 0aeefa3..a4a3a60 100644 --- a/app-backend/src/docker/DockerApi.js +++ b/app-backend/src/docker/DockerApi.js @@ -6,6 +6,17 @@ const CaptainConstants = require('../utils/CaptainConstants'); const Logger = require('../utils/Logger'); const EnvVars = require('../utils/EnvVars'); +function safeParseChuck(chunk) { + try { + return JSON.parse(chunk); + } + catch (ignore) { + return { + stream: 'Cannot parse ' + chunk + } + } +} + class DockerApi { constructor(connectionParams) { @@ -174,7 +185,7 @@ class DockerApi { }); } - buildImageFromDockerFile(imageName, newVersion, tarballFilePath) { + buildImageFromDockerFile(imageName, newVersion, tarballFilePath, buildLogs) { const self = this; @@ -191,10 +202,6 @@ class DockerApi { return new Promise(function (resolve, reject) { let errorMessage = ''; - let logsBeforeError = []; - for (let i = 0; i < 20; i++) { - logsBeforeError.push(''); - } stream.setEncoding('utf8'); @@ -202,21 +209,22 @@ class DockerApi { stream.on('data', function (chunk) { Logger.dev('stream data ' + chunk); - chunk = JSON.parse(chunk); + chunk = safeParseChuck(chunk); let chuckStream = chunk.stream; if (chuckStream) { // Logger.dev('stream data ' + chuckStream); - logsBeforeError.shift(); - logsBeforeError.push(chuckStream); + buildLogs.log(chuckStream); } if (chunk.error) { Logger.e(chunk.error); - Logger.e(JSON.stringify(chunk.errorDetail)); - errorMessage += '\n [truncated] \n'; - errorMessage += logsBeforeError.join(''); + let errorDetails = JSON.stringify(chunk.errorDetail); + Logger.e(errorDetails); + buildLogs.log(errorDetails); + buildLogs.log(chunk.error); errorMessage += '\n'; + errorMessage += errorDetails; errorMessage += chunk.error; } }); @@ -279,7 +287,7 @@ class DockerApi { stream.on('data', function (chunk) { Logger.dev('stream data ' + chunk); - chunk = JSON.parse(chunk); + chunk = safeParseChuck(chunk); let chuckStream = chunk.stream; if (chuckStream) { @@ -433,7 +441,7 @@ class DockerApi { }); } - pushImage(imageName, newVersion, authObj) { + pushImage(imageName, newVersion, authObj, buildLogs) { const self = this; @@ -454,23 +462,53 @@ class DockerApi { }) .then(function (stream) { - return new Promise(function (resolve) { + return new Promise(function (resolve, reject) { + let errorMessage = ''; + + stream.setEncoding('utf8'); + + // THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE. stream.on('data', function (chunk) { - // THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE. - // ('stream data ' + chunk); - }); + Logger.dev('stream data ' + chunk); + chunk = safeParseChuck(chunk); + + let chuckStream = chunk.stream; + if (chuckStream) { + // Logger.dev('stream data ' + chuckStream); + buildLogs.log(chuckStream); + } + + if (chunk.error) { + Logger.e(chunk.error); + let errorDetails = JSON.stringify(chunk.errorDetail); + Logger.e(errorDetails); + buildLogs.log(errorDetails); + buildLogs.log(chunk.error); + errorMessage += '\n'; + errorMessage += errorDetails; + errorMessage += chunk.error; + } + }); // stream.pipe(process.stdout, {end: true}); // IncomingMessage // https://nodejs.org/api/stream.html#stream_event_end stream.on('end', function () { + if (errorMessage) { + reject(errorMessage); + return; + } resolve(); }); - }); + stream.on('error', function (chunk) { + errorMessage += chunk; + }); + + }); }); } diff --git a/app-backend/src/routes/AppDataRouter.js b/app-backend/src/routes/AppDataRouter.js index e2e4073..3ddde0d 100644 --- a/app-backend/src/routes/AppDataRouter.js +++ b/app-backend/src/routes/AppDataRouter.js @@ -8,6 +8,39 @@ const fs = require('fs-extra'); const TEMP_UPLOAD = 'temp_upload/'; const upload = multer({dest: TEMP_UPLOAD}); + +router.get('/:appName/', function (req, res, next) { + + let appName = req.params.appName; + let serviceManager = res.locals.user.serviceManager; + + return Promise.resolve() + .then(function () { + + return serviceManager.getBuildStatus(appName); + + }) + .then(function (data) { + + let baseApi = new BaseApi(ApiStatusCodes.STATUS_OK, 'App build status retrieved'); + baseApi.data = data; + res.send(baseApi); + + }) + .catch(function (error) { + + Logger.e(error); + + if (error && error.captainErrorType) { + res.send(new BaseApi(error.captainErrorType, error.apiMessage)); + return; + } + + res.sendStatus(500); + }); + +}); + router.post('/:appName/', function (req, res, next) { let dataStore = res.locals.user.dataStore; @@ -39,6 +72,8 @@ router.post('/:appName/', upload.single('sourceFile'), function (req, res, next) let dataStore = res.locals.user.dataStore; let serviceManager = res.locals.user.serviceManager; + let isDetachedBuild = !!req.query.detached; + console.log('---------------' + JSON.stringify(req.query)); let appName = req.params.appName; @@ -86,34 +121,19 @@ router.post('/:appName/', upload.single('sourceFile'), function (req, res, next) }) .then(function () { - return serviceManager.createImage(appName, { - pathToSrcTarballFile: tarballSourceFilePath - }, gitHash); - - }) - .then(function (version) { - - fs.removeSync(tarballSourceFilePath); - return version; - - }) - .catch(function (error) { - - return new Promise(function (resolve, reject) { - fs.removeSync(tarballSourceFilePath); - reject(error); - }) - - }) - .then(function (version) { - - return serviceManager.ensureServiceInitedAndUpdated(appName, version); - - }) - .then(function () { - - res.send(new BaseApi(ApiStatusCodes.STATUS_OK, 'App Data Saved')); - + if (isDetachedBuild) { + res.send(new BaseApi(ApiStatusCodes.STATUS_OK_DEPLOY_STARTED, 'Deploy is started')); + startBuildProcess() + .catch(function (error) { + Logger.e(error); + }); + } + else { + return startBuildProcess() + .then(function () { + res.send(new BaseApi(ApiStatusCodes.STATUS_OK, 'Deploy is done')); + }); + } }) .catch(function (error) { @@ -130,8 +150,47 @@ router.post('/:appName/', upload.single('sourceFile'), function (req, res, next) res.send(new BaseApi(ApiStatusCodes.STATUS_ERROR_GENERIC, error.stack + '')); - + try { + fs.removeSync(tarballSourceFilePath); + } catch (ignore) { + } }); + + + function startBuildProcess() { + + return serviceManager + .createImage(appName, { + pathToSrcTarballFile: tarballSourceFilePath + }, gitHash) + .then(function (version) { + + fs.removeSync(tarballSourceFilePath); + return version; + + }) + .catch(function (error) { + + return new Promise(function (resolve, reject) { + fs.removeSync(tarballSourceFilePath); + reject(error); + }) + + }) + .then(function (version) { + + return serviceManager.ensureServiceInitedAndUpdated(appName, version); + + }) + .catch(function (error) { + + return new Promise(function (resolve, reject) { + serviceManager.logBuildFailed(appName, error); + reject(error); + }) + + }); + } }); module.exports = router; diff --git a/app-backend/src/user/ServiceManager.js b/app-backend/src/user/ServiceManager.js index a89b39d..8297d0a 100644 --- a/app-backend/src/user/ServiceManager.js +++ b/app-backend/src/user/ServiceManager.js @@ -10,6 +10,7 @@ const Authenticator = require('./Authenticator'); const GitHelper = require('../utils/GitHelper'); const uuid = require('uuid/v4'); +const BUILD_LOG_SIZE = 50; const SOURCE_FOLDER_NAME = 'src'; const DOCKER_FILE = 'Dockerfile'; const CAPTAIN_DEFINITION_FILE = 'captain-definition'; @@ -32,6 +33,47 @@ function getCaptainDefinitionTempFolder(serviceName, randomSuffix) { return CaptainConstants.captainDefinitionTempDir + '/' + serviceName + '/' + randomSuffix; } + +class BuildLog { + + constructor(size) { + this.size = size; + this.clear(); + } + + onBuildFailed(error) { + this.log('Deploy failed!'); + this.log(error); + this.isBuildFailed = true; + } + + clear() { + this.isBuildFailed = false; + this.firstLineNumber = -this.size; + this.lines = []; + for (let i = 0; i < this.size; i++) { + this.lines.push(''); + } + } + + log(msg) { + msg = (msg || '') + ''; + this.lines.shift(); + this.lines.push(msg); + this.firstLineNumber++; + } + + getLogs() { + const self = this; + // if we don't copy the object, "lines" can get changed but firstLineNumber stay as is, causing bug! + return JSON.parse(JSON.stringify({ + lines: self.lines, + firstLineNumber: self.firstLineNumber + })); + } +} + + class ServiceManager { constructor(user, dockerApi, loadBalancerManager) { @@ -40,6 +82,7 @@ class ServiceManager { this.dockerApi = dockerApi; this.loadBalancerManager = loadBalancerManager; this.activeBuilds = {}; + this.buildLogs = {}; this.isReady = true; @@ -115,6 +158,11 @@ class ServiceManager { let dockerFilePath = null; this.activeBuilds[appName] = true; + this.buildLogs[appName] = this.buildLogs[appName] || new BuildLog(BUILD_LOG_SIZE); + + this.buildLogs[appName].clear(); + this.buildLogs[appName].log('------------------------- ' + (new Date())); + this.buildLogs[appName].log('Build started for ' + appName); return Promise.resolve() .then(function () { @@ -298,7 +346,7 @@ class ServiceManager { .then(function () { return dockerApi - .buildImageFromDockerFile(imageName, newVersion, tarballFilePath) + .buildImageFromDockerFile(imageName, newVersion, tarballFilePath, self.buildLogs[appName]) .catch(function (error) { throw ApiStatusCodes.createError(ApiStatusCodes.BUILD_ERROR, ('' + error).trim()); }) @@ -328,7 +376,7 @@ class ServiceManager { Logger.d('Docker Auth is found. Pushing the image...'); return dockerApi - .pushImage(imageName, newVersion, authObj); + .pushImage(imageName, newVersion, authObj, self.buildLogs[appName]); }) .then(function () { @@ -770,6 +818,23 @@ class ServiceManager { return !!this.activeBuilds[appName]; } + getBuildStatus(appName) { + const self = this; + this.buildLogs[appName] = this.buildLogs[appName] || new BuildLog(BUILD_LOG_SIZE); + + return { + isAppBuilding: self.isAppBuilding(appName), + logs: self.buildLogs[appName].getLogs(), + isBuildFailed: self.buildLogs[appName].isBuildFailed + } + } + + logBuildFailed(appName, error) { + error = (error || '') + ''; + this.buildLogs[appName] = this.buildLogs[appName] || new BuildLog(BUILD_LOG_SIZE); + this.buildLogs[appName].onBuildFailed(error); + } + updateServiceOnDefinitionUpdate(appName) { let serviceName = this.dataStore.getServiceName(appName); diff --git a/app-cli/captainduckduck-deploy.js b/app-cli/captainduckduck-deploy.js index 525ead3..e007a54 100755 --- a/app-cli/captainduckduck-deploy.js +++ b/app-cli/captainduckduck-deploy.js @@ -131,7 +131,6 @@ function savePropForDirectory(propType, propValue) { function getDefaultMachine() { let machine = getPropForDirectory(MACHINE_TO_DEPLOY); - console.log(machine); if (machine) { return machine.name; } @@ -227,15 +226,15 @@ if (!program.default || defaultInvalid) { function deployTo(machineToDeploy, branchToPush, appName) { if (!commandExistsSync('git')) { console.log(chalk.red('"git" command not found...')); - console.log(chalk.red('Captain needs "git" to create zip file of your source files...')); + console.log(chalk.red('Captain needs "git" to create tar file of your source files...')); console.log(' '); process.exit(1); } - let zipFileNameToDeploy = 'temporary-captain-to-deploy.zip'; + let zipFileNameToDeploy = 'temporary-captain-to-deploy.tar'; let zipFileFullPath = path.join(process.cwd(), zipFileNameToDeploy); - console.log('Saving zip file to:'); + console.log('Saving tar file to:'); console.log(zipFileFullPath); console.log(' '); @@ -298,7 +297,7 @@ function sendFileToCaptain(machineToDeploy, zipFileFullPath, appName, gitHash, b let options = { - url: machineToDeploy.baseUrl + '/api/v1/user/appData/' + appName, + url: machineToDeploy.baseUrl + '/api/v1/user/appData/' + appName + '/?detached=1', headers: { 'x-namespace': 'captain', 'x-captain-auth': machineToDeploy.authToken @@ -337,7 +336,7 @@ function sendFileToCaptain(machineToDeploy, zipFileFullPath, appName, gitHash, b return; } - if (data.status !== 100) { + if (data.status !== 100 && data.status !== 101) { throw new Error(JSON.stringify(data, null, 2)); } @@ -345,8 +344,14 @@ function sendFileToCaptain(machineToDeploy, zipFileFullPath, appName, gitHash, b savePropForDirectory(BRANCH_TO_PUSH, branchToPush); savePropForDirectory(MACHINE_TO_DEPLOY, machineToDeploy); - console.log(chalk.green('Deployed successful: ') + appName); - console.log(' '); + if (data.status === 100) { + console.log(chalk.green('Deployed successful: ') + appName); + console.log(' '); + } else if (data.status === 101) { + console.log(chalk.green('Building started: ') + appName); + console.log(' '); + startFetchingBuildLogs(machineToDeploy, appName); + } return; } @@ -384,6 +389,97 @@ function sendFileToCaptain(machineToDeploy, zipFileFullPath, appName, gitHash, b } +var lastLineNumberPrinted = -10000; // we want to show all lines to begin with! + +function startFetchingBuildLogs(machineToDeploy, appName) { + + let options = { + url: machineToDeploy.baseUrl + '/api/v1/user/appData/' + appName, + headers: { + 'x-namespace': 'captain', + 'x-captain-auth': machineToDeploy.authToken + }, + method: 'GET' + }; + + function onLogRetrieved(data) { + + if (data) { + var lines = data.logs.lines; + var firstLineNumberOfLogs = data.logs.firstLineNumber; + var firstLinesToPrint = 0; + if (firstLineNumberOfLogs > lastLineNumberPrinted) { + + if (firstLineNumberOfLogs < 0) { + // This is the very first fetch, probably firstLineNumberOfLogs is around -50 + firstLinesToPrint = -firstLineNumberOfLogs; + } else { + console.log('[[ TRUNCATED ]]'); + } + + } else { + firstLinesToPrint = lastLineNumberPrinted - firstLineNumberOfLogs; + } + + lastLineNumberPrinted = firstLineNumberOfLogs + lines.length; + + for (var i = firstLinesToPrint; i < lines.length; i++) { + console.log((lines[i] || '').trim()); + } + } + + if (data && !data.isAppBuilding) { + console.log(' '); + if (!data.isBuildFailed) { + console.log(chalk.green('Deployed successful: ') + appName); + console.log(chalk.magenta('App is available at ') + (machineToDeploy.baseUrl.replace('//captain.', '//' + appName + '.'))); + } else { + console.error(chalk.red('\nSomething bad happened. Cannot deploy "' + appName + '"\n')); + } + console.log(' '); + return; + } + + setTimeout(function () { + startFetchingBuildLogs(machineToDeploy, appName); + }, 2000); + } + + + function callback(error, response, body) { + + try { + + if (!error && response.statusCode === 200) { + + let data = JSON.parse(body); + + if (data.status !== 100) { + throw new Error(JSON.stringify(data, null, 2)); + } + + onLogRetrieved(data.data); + + return; + } + + if (error) { + throw new Error(error) + } + + throw new Error(response ? JSON.stringify(response, null, 2) : 'Response NULL'); + + } catch (error) { + + console.error(chalk.red('\nSomething while retrieving app build logs.. "' + error + '"\n')); + + onLogRetrieved(null); + } + } + + request(options, callback); +} + function requestLogin(serverName, serverAddress, loginCallback) { console.log('Your auth token is not valid anymore. Try to login again.');