Skip to content

Commit

Permalink
fix(ghost): push is behaving like it should
Browse files Browse the repository at this point in the history
  • Loading branch information
allardy committed Aug 14, 2019
1 parent a8c7aad commit 533f184
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 212 deletions.
221 changes: 221 additions & 0 deletions src/bp/bpfs.ts
@@ -0,0 +1,221 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import chalk from 'chalk'
import { bytesToString } from 'core/misc/utils'
import followRedirects from 'follow-redirects'
import fse from 'fs-extra'
import glob from 'glob'
import _ from 'lodash'
import path from 'path'
import rimraf from 'rimraf'

import { extractArchive } from './core/misc/archive'
import { createArchiveFromFolder } from './core/misc/archive'
import { asBytes } from './core/misc/utils'
import { FileChanges } from './core/services'

// This is a dependency of axios, and sets the default body limit to 10mb. Need it to be higher
followRedirects.maxBodyLength = asBytes('500mb')

// If the push will cause one of these actions, then a force will be required
const blockingActions = ['del', 'edit']

/**
* These files must be ignored when pushing them to the remote host.
* assets and models are auto-generated and .js.map files are not required
*/
const pushedArchiveIgnoredFiles = ['assets/**/*', 'bots/*/models/**', '**/*.js.map']

class BPFS {
private serverUrl: string
private authToken: string
private targetDir: string

constructor(args, action: string) {
this.serverUrl = args.url.replace(/\/+$/, '')
this.authToken = args.authToken
this.targetDir = args.targetDir

if (!this.serverUrl || !this.authToken || !this.authToken.length) {
this._endWithError(`Missing parameter "url" or "authToken"`)
}

if (!this.targetDir || (action === 'push' && !fse.existsSync(path.resolve(this.targetDir)))) {
this._endWithError(`Target directory is not valid: "${this.targetDir}"`)
}
}

async pullChanges() {
const cleanBefore = process.argv.includes('--clean')
const axiosClient = this._getPullAxiosClient()

try {
// We clear only those two folders, so assets are preserved
if (cleanBefore) {
console.log(chalk.blue(`Cleaning data folder before pulling data...`))
await this._clearDir(path.resolve(this.targetDir, 'global'))
await this._clearDir(path.resolve(this.targetDir, 'bots'))
} else if (fse.existsSync(path.resolve(this.targetDir))) {
const fileCount = await this._filesCount(path.resolve(this.targetDir))
console.log(chalk.blue(`Remote files will be pulled in an existing folder containing ${fileCount} files`))
}

console.log(chalk.blue(`Pulling all remote changes from ${this.printUrl} to ${this.printTarget} ...`))

const { data: archive } = await axiosClient.get('export')

await extractArchive(archive, this.targetDir)
console.log(
chalk.green(
`Successfully extracted changes on your local file system. Archive size: ${bytesToString(archive.length)}`
)
)
} catch (err) {
const error = err.response ? `${err.response.statusText} (${err.response.status})` : err.message
this._endWithError(`Could not pull changes: ${error}`)
}
}

async pushChanges() {
const useForce = process.argv.includes('--force')
const dryRun = process.argv.includes('--dry')

try {
console.log(chalk.blue(`Preparing an archive of your local files...`))

const archive = await createArchiveFromFolder(this.targetDir, pushedArchiveIgnoredFiles)
const axiosClient = this._getPushAxiosClient(archive.length)

console.log(
chalk.blue(`Sending archive to server for comparison... (Archive size: ${bytesToString(archive.length)})`)
)
const { data } = await axiosClient.post('changes', archive)
const { changeList, blockingChanges, localFiles } = this._processChanges(data)

if (_.isEmpty(blockingChanges) || useForce) {
this._printChangeList(changeList)

if (dryRun) {
console.log(chalk.yellow(`Dry run completed. Nothing was pushed to server`))
return process.exit()
}

console.log(chalk.blue(`Pushing local changes to ${this.printUrl}...`))
useForce && console.log(chalk.yellow('Using --force'))

await axiosClient.post('update', archive)
console.log(chalk.green(`Successfully pushed ${localFiles.length} local files to remote server!`))
} else {
this._printOutOfSync()
this._printChangeList(changeList)
console.log(chalk.red(`Nothing was pushed on the remote server.`))
}
} catch (err) {
const error = err.response ? `${err.response.statusText} (${err.response.status})` : err.message
this._endWithError(`Could not push changes: ${error}`)
}
}

private async _filesCount(directory: string) {
const files: string[] = await Promise.fromCallback(cb =>
glob('**/*', { cwd: directory, nodir: true, dot: true }, cb)
)
return files.length
}

private _processChanges(data: FileChanges) {
const changeList = _.flatten(data.map(x => x.changes))
return {
localFiles: _.flatten(data.map(x => x.localFiles)),
blockingChanges: changeList.filter(x => blockingActions.includes(x.action)),
changeList
}
}

private _getPushAxiosClient(archiveSize: number): AxiosInstance {
return axios.create({
baseURL: `${this.serverUrl}/api/v1/admin/versioning`,
headers: {
Authorization: `Bearer ${this.authToken}`,
'Content-Type': 'application/tar+gzip',
'Content-Disposition': `attachment; filename=archive_${Date.now()}.tgz`,
'Content-Length': archiveSize
}
})
}

private _getPullAxiosClient(): AxiosInstance {
return axios.create({
baseURL: `${this.serverUrl}/api/v1/admin/versioning`,
headers: {
Authorization: `Bearer ${this.authToken}`
},
responseType: 'arraybuffer'
})
}

private async _clearDir(destination: string): Promise<void> {
if (fse.existsSync(destination)) {
return Promise.fromCallback(cb => rimraf(destination, cb))
}
}

private _printLine({ action, path, add, del }): string {
if (action === 'add') {
return chalk.green(` + ${path}`)
} else if (action === 'del') {
return chalk.red(` - ${path}`)
} else if (action === 'edit') {
return ` o ${path} (${chalk.green('+' + add)} / -${chalk.redBright(del)})`
}
return ''
}

private _printOutOfSync() {
console.log(chalk`
{bold Out of sync! }
You have changes on your file system that aren't synchronized to the remote environment.
(Replace {bold "push"} with {bold "pull"} in your command to pull remote changes on your file system)
(Use ${chalk.yellow('--force')} to overwrite the remote files by your local files)
`)
}

private _printChangeList(changes) {
if (!changes.length) {
return
}

const lines = _.orderBy(changes, 'action')
.map(this._printLine)
.join('\n')

return console.log(`Differences between your local changes (green) vs remote changes (red):
${lines}
`)
}

private _endWithError(message: string) {
console.log(chalk.red(`${chalk.bold('Error:')} ${message}`))
process.exit()
}

get printUrl() {
return chalk.bold(this.serverUrl)
}

get printTarget() {
return chalk.bold(path.resolve(this.targetDir))
}
}

export default async (argv, action) => {
const bpfs = new BPFS(argv, action)
console.log(`\n`)
if (action === 'pull') {
await bpfs.pullChanges()
} else if (action === 'push') {
await bpfs.pushChanges()
}
console.log(`\n`)
}
9 changes: 9 additions & 0 deletions src/bp/core/misc/utils.ts
Expand Up @@ -84,3 +84,12 @@ export const asBytes = (size: string) => {

return Number(matches[1])
}

export const bytesToString = (bytes: number): string => {
const units = ['bytes', 'kb', 'mb', 'gb', 'tb']
const power = Math.log2(bytes)
const unitNumber = Math.min(Math.floor(power / 10), 4)
const mantisse = bytes / Math.pow(2, unitNumber * 10)

return `${mantisse.toFixed(0)} ${units[unitNumber]}`
}
6 changes: 2 additions & 4 deletions src/bp/core/routers/admin/index.ts
Expand Up @@ -7,7 +7,6 @@ import { GhostService } from 'core/services'
import { AlertingService } from 'core/services/alerting-service'
import AuthService, { TOKEN_AUDIENCE } from 'core/services/auth/auth-service'
import { BotService } from 'core/services/bot-service'
import { CMSService } from 'core/services/cms'
import { MonitoringService } from 'core/services/monitoring'
import { WorkspaceService } from 'core/services/workspace-service'
import { RequestHandler, Router } from 'express'
Expand Down Expand Up @@ -45,15 +44,14 @@ export class AdminRouter extends CustomRouter {
configProvider: ConfigProvider,
monitoringService: MonitoringService,
alertingService: AlertingService,
moduleLoader: ModuleLoader,
cmsService: CMSService
moduleLoader: ModuleLoader
) {
super('Admin', logger, Router({ mergeParams: true }))
this.checkTokenHeader = checkTokenHeader(this.authService, TOKEN_AUDIENCE)
this.botsRouter = new BotsRouter(logger, this.workspaceService, this.botService, configProvider)
this.usersRouter = new UsersRouter(logger, this.authService, this.workspaceService)
this.licenseRouter = new LicenseRouter(logger, this.licenseService, configProvider)
this.versioningRouter = new VersioningRouter(logger, this.ghostService, this.botService, cmsService)
this.versioningRouter = new VersioningRouter(logger, this.ghostService, this.botService)
this.rolesRouter = new RolesRouter(logger, this.workspaceService)
this.serverRouter = new ServerRouter(logger, monitoringService, alertingService, configProvider, ghostService)
this.languagesRouter = new LanguagesRouter(logger, moduleLoader, this.workspaceService)
Expand Down
30 changes: 9 additions & 21 deletions src/bp/core/routers/admin/versioning.ts
Expand Up @@ -2,7 +2,6 @@ import { Logger } from 'botpress/sdk'
import { extractArchive } from 'core/misc/archive'
import { GhostService } from 'core/services'
import { BotService } from 'core/services/bot-service'
import { CMSService } from 'core/services/cms'
import { Router } from 'express'
import _ from 'lodash'
import mkdirp from 'mkdirp'
Expand All @@ -12,37 +11,23 @@ import tmp from 'tmp'
import { CustomRouter } from '../customRouter'

export class VersioningRouter extends CustomRouter {
constructor(
logger: Logger,
private ghost: GhostService,
private botService: BotService,
private cmsService: CMSService
) {
constructor(private logger: Logger, private ghost: GhostService, private botService: BotService) {
super('Versioning', logger, Router({ mergeParams: true }))
this.setupRoutes()
}

setupRoutes() {
this.router.get(
'/pending',
this.asyncMiddleware(async (req, res) => {
const botIds = await this.botService.getBotsIds()
res.send(await this.ghost.getPending(botIds))
})
)

this.router.get(
'/export',
this.asyncMiddleware(async (req, res) => {
const botIds = await this.botService.getBotsIds()
const tarball = await this.ghost.exportArchive(botIds)
const archive = await this.ghost.exportArchive()

res.writeHead(200, {
'Content-Type': 'application/tar+gzip',
'Content-Disposition': `attachment; filename=archive_${Date.now()}.tgz`,
'Content-Length': tarball.length
'Content-Length': archive.length
})
res.end(tarball)
res.end(archive)
})
)

Expand All @@ -54,9 +39,9 @@ export class VersioningRouter extends CustomRouter {
try {
await this.extractArchiveFromRequest(req, tmpDir.name)

res.send({ changes: await this.ghost.listFileChanges(tmpDir.name) })
res.send(await this.ghost.listFileChanges(tmpDir.name))
} catch (error) {
res.status(500).send('Error while pushing changes')
res.status(500).send('Error while listing changes')
} finally {
tmpDir.removeCallback()
}
Expand All @@ -74,6 +59,9 @@ export class VersioningRouter extends CustomRouter {
await this.extractArchiveFromRequest(req, tmpDir.name)
const newBotIds = await this.ghost.forceUpdate(tmpDir.name)

this.logger.info(`Unmounting bots: ${beforeBotIds.join(', ')}`)
this.logger.info(`Mounting bots: ${newBotIds.join(', ')}`)

// Unmount all previous bots and re-mount only the remaining (and new) bots
await Promise.map(beforeBotIds, id => this.botService.unmountBot(id))
await Promise.map(newBotIds, id => this.botService.mountBot(id))
Expand Down
9 changes: 4 additions & 5 deletions src/bp/core/server.ts
@@ -1,11 +1,12 @@
import bodyParser from 'body-parser'
import { AxiosBotConfig, AxiosOptions, http, Logger, RouterOptions } from 'botpress/sdk'
import LicensingService from 'common/licensing-service'
import { RequestWithUser } from 'common/typings'
import session from 'cookie-session'
import cors from 'cors'
import errorHandler from 'errorhandler'
import { UnlicensedError } from 'errors'
import express, { Response, NextFunction } from 'express'
import express, { NextFunction, Response } from 'express'
import { Request } from 'express-serve-static-core'
import rewrite from 'express-urlrewrite'
import fs from 'fs'
Expand All @@ -30,7 +31,7 @@ import { HintsRouter } from './routers/bots/hints'
import { isDisabled } from './routers/conditionalMiddleware'
import { InvalidExternalToken, PaymentRequiredError } from './routers/errors'
import { ShortLinksRouter } from './routers/shortlinks'
import { monitoringMiddleware, needPermissions, hasPermissions } from './routers/util'
import { hasPermissions, monitoringMiddleware, needPermissions } from './routers/util'
import { GhostService } from './services'
import ActionService from './services/action/action-service'
import { AlertingService } from './services/alerting-service'
Expand All @@ -49,7 +50,6 @@ import { MonitoringService } from './services/monitoring'
import { NotificationsService } from './services/notification/service'
import { WorkspaceService } from './services/workspace-service'
import { TYPES } from './types'
import { RequestWithUser, AuthRole } from 'common/typings'

const BASE_API_PATH = '/api/v1'
const SERVER_USER_STRATEGY = 'default' // The strategy isn't validated for the userver user, it could be anything.
Expand Down Expand Up @@ -150,8 +150,7 @@ export default class HTTPServer {
this.configProvider,
this.monitoringService,
this.alertingService,
moduleLoader,
cmsService
moduleLoader
)
this.shortlinksRouter = new ShortLinksRouter(this.logger)
this.botsRouter = new BotsRouter({
Expand Down

0 comments on commit 533f184

Please sign in to comment.