/*jshint esnext: true */ /*global ArangoServerState, ArangoClusterInfo, ArangoClusterComm */ 'use strict'; //////////////////////////////////////////////////////////////////////////////// /// @brief Foxx application manager /// /// @file /// /// DISCLAIMER /// /// Copyright 2013 triagens GmbH, Cologne, Germany /// /// 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. /// /// Copyright holder is triAGENS GmbH, Cologne, Germany /// /// @author Dr. Frank Celler /// @author Michael Hackstein /// @author Copyright 2013, triAGENS GmbH, Cologne, Germany //////////////////////////////////////////////////////////////////////////////// // ----------------------------------------------------------------------------- // --SECTION-- imports // ----------------------------------------------------------------------------- const _ = require('underscore'); const fs = require('fs'); const joi = require('joi'); const util = require('util'); const semver = require('semver'); const utils = require('@arangodb/foxx/manager-utils'); const store = require('@arangodb/foxx/store'); const deprecated = require('@arangodb/deprecated'); const FoxxService = require('@arangodb/foxx/service'); const TemplateEngine = require('@arangodb/foxx/templateEngine').Engine; const routeApp = require('@arangodb/foxx/routing').routeApp; const exportApp = require('@arangodb/foxx/routing').exportApp; const invalidateExportCache = require('@arangodb/foxx/routing').invalidateExportCache; const formatUrl = require('url').format; const parseUrl = require('url').parse; const arangodb = require('@arangodb'); const ArangoError = arangodb.ArangoError; const db = arangodb.db; const checkParameter = arangodb.checkParameter; const errors = arangodb.errors; const cluster = require('@arangodb/cluster'); const download = require('internal').download; const executeGlobalContextFunction = require('internal').executeGlobalContextFunction; const actions = require('@arangodb/actions'); const plainServerVersion = require('@arangodb').plainServerVersion; const throwDownloadError = arangodb.throwDownloadError; const throwFileNotFound = arangodb.throwFileNotFound; // Regular expressions for joi patterns const RE_FQPATH = /^\//; const RE_EMPTY = /^$/; const RE_NOT_FQPATH = /^[^\/]/; const RE_NOT_EMPTY = /./; const manifestSchema = { assets: ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, ( joi.object().required() .keys({ files: ( joi.array().required() .items(joi.string().required()) ), contentType: joi.string().optional() }) )) ), author: joi.string().allow('').default(''), configuration: ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, ( joi.object().required() .keys({ default: joi.any().optional(), type: ( joi.only(Object.keys(utils.parameterTypes)) .default('string') ), description: joi.string().optional(), required: joi.boolean().default(true) }) )) ), contributors: joi.array().optional(), controllers: joi.alternatives().try( joi.string().optional(), ( joi.object().optional() .pattern(RE_NOT_FQPATH, joi.forbidden()) .pattern(RE_FQPATH, joi.string().required()) ) ), defaultDocument: joi.string().allow('').allow(null).default('index.html'), dependencies: ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, joi.alternatives().try( joi.string().required(), joi.object().required() .keys({ name: joi.string().default('*'), version: joi.string().default('*'), required: joi.boolean().default(true) }) )) ), description: joi.string().allow('').default(''), engines: ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, joi.string().required()) ), exports: joi.alternatives().try( joi.string().optional(), ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, joi.string().required()) ) ), files: ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, joi.alternatives().try(joi.string().required(), joi.object().required())) ), isSystem: joi.boolean().default(false), keywords: joi.array().optional(), lib: joi.string().default('.'), license: joi.string().optional(), name: joi.string().regex(/^[-_a-z][-_a-z0-9]*$/i).required(), repository: ( joi.object().optional() .keys({ type: joi.string().required(), url: joi.string().required() }) ), scripts: ( joi.object().optional() .pattern(RE_EMPTY, joi.forbidden()) .pattern(RE_NOT_EMPTY, joi.string().required()) .default(Object, 'empty scripts object') ), setup: joi.string().optional(), teardown: joi.string().optional(), tests: ( joi.alternatives() .try( joi.string().required(), ( joi.array().optional() .items(joi.string().required()) .default(Array, 'empty test files array') ) ) ), thumbnail: joi.string().optional(), version: joi.string().required(), rootElement: joi.boolean().default(false) }; // ----------------------------------------------------------------------------- // --SECTION-- private variables // ----------------------------------------------------------------------------- var appCache = {}; var usedSystemMountPoints = [ '/_admin/aardvark', // Admin interface. '/_system/cerberus', // Password recovery. '/_api/gharial', // General_Graph API. '/_system/sessions', // Sessions. '/_system/users', // Users. '/_system/simple-auth' // Authentication. ]; // ----------------------------------------------------------------------------- // --SECTION-- private functions // ----------------------------------------------------------------------------- //////////////////////////////////////////////////////////////////////////////// /// @brief Searches through a tree of files and returns true for all app roots //////////////////////////////////////////////////////////////////////////////// function filterAppRoots(folder) { return /[\\\/]APP$/i.test(folder) && !/(APP[\\\/])(.*)APP$/i.test(folder); } //////////////////////////////////////////////////////////////////////////////// /// @brief Trigger reload routing /// Triggers reloading of routes in this as well as all other threads. //////////////////////////////////////////////////////////////////////////////// function reloadRouting() { executeGlobalContextFunction('reloadRouting'); actions.reloadRouting(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Resets the app cache //////////////////////////////////////////////////////////////////////////////// function resetCache() { appCache = {}; invalidateExportCache(); } //////////////////////////////////////////////////////////////////////////////// /// @brief lookup app in cache /// Returns either the app or undefined if it is not cached. //////////////////////////////////////////////////////////////////////////////// function lookupApp(mount) { var dbname = arangodb.db._name(); if (!appCache.hasOwnProperty(dbname) || Object.keys(appCache[dbname]).length === 0) { refillCaches(dbname); } if (!appCache[dbname].hasOwnProperty(mount)) { refillCaches(dbname); if (appCache[dbname].hasOwnProperty(mount)) { return appCache[dbname][mount]; } throw new ArangoError({ errorNum: errors.ERROR_APP_NOT_FOUND.code, errorMessage: 'App not found at: ' + mount }); } return appCache[dbname][mount]; } //////////////////////////////////////////////////////////////////////////////// /// @brief refills the routing cache //////////////////////////////////////////////////////////////////////////////// function refillCaches(dbname) { var cache = appCache[dbname] = {}; var cursor = utils.getStorage().all(); var routes = []; while (cursor.hasNext()) { var config = _.clone(cursor.next()); var app = new FoxxService(config); var mount = app.mount; cache[mount] = app; routes.push(mount); } return routes; } //////////////////////////////////////////////////////////////////////////////// /// @brief routes of an foxx //////////////////////////////////////////////////////////////////////////////// function routes(mount) { var app = lookupApp(mount); return routeApp(app); } //////////////////////////////////////////////////////////////////////////////// /// @brief Makes sure all system apps are mounted. //////////////////////////////////////////////////////////////////////////////// function checkMountedSystemApps(dbname) { var i, mount; var collection = utils.getStorage(); for (i = 0; i < usedSystemMountPoints.length; ++i) { mount = usedSystemMountPoints[i]; delete appCache[dbname][mount]; var definition = collection.firstExample({mount: mount}); if (definition !== null) { collection.remove(definition._key); } _scanFoxx(mount, {}); executeAppScript('setup', lookupApp(mount)); } } //////////////////////////////////////////////////////////////////////////////// /// @brief check a manifest for completeness /// /// this implements issue #590: Manifest Lint //////////////////////////////////////////////////////////////////////////////// function checkManifest(filename, manifest) { const serverVersion = plainServerVersion(); const validationErrors = []; Object.keys(manifestSchema).forEach(function (key) { let schema = manifestSchema[key]; let value = manifest[key]; let result = joi.validate(value, schema); if (result.value !== undefined) { manifest[key] = result.value; } if (result.error) { let error = result.error.message.replace(/^"value"/, `"${key}"`); let message = `Manifest "${filename}": attribute ${error} (was "${util.format(value)}").`; validationErrors.push(message); console.error(message); } }); if ( manifest.engines && manifest.engines.arangodb && !semver.satisfies(serverVersion, manifest.engines.arangodb) ) { console.warn( `Manifest "${filename}" for app "${manifest.name}":` + ` ArangoDB version ${serverVersion} probably not compatible` + ` with expected version ${manifest.engines.arangodb}.` ); } if (manifest.setup && manifest.setup !== manifest.scripts.setup) { deprecated('3.0', ( `Manifest "${filename}" for app "${manifest.name}" contains deprecated attribute "setup",` + ` use "scripts.setup" instead.` )); manifest.scripts.setup = manifest.scripts.setup || manifest.setup; delete manifest.setup; } if (manifest.teardown && manifest.teardown !== manifest.scripts.teardown) { deprecated('3.0', ( `Manifest "${filename}" for app "${manifest.name}" contains deprecated attribute "teardown",` + ` use "scripts.teardown" instead.` )); manifest.scripts.teardown = manifest.scripts.teardown || manifest.teardown; delete manifest.teardown; } if (manifest.assets) { deprecated('3.0', ( `Manifest "${filename}" for app "${manifest.name}" contains deprecated attribute "assets",` + ` use "files" and an external build tool instead.` )); } Object.keys(manifest).forEach(function (key) { if (!manifestSchema[key]) { console.warn(`Manifest "${filename}" for app "${manifest.name}": unknown attribute "${key}"`); } }); if (validationErrors.length) { throw new ArangoError({ errorNum: errors.ERROR_INVALID_APPLICATION_MANIFEST.code, errorMessage: validationErrors.join('\n') }); } else { if (manifest.dependencies) { Object.keys(manifest.dependencies).forEach(function (key) { const dependency = manifest.dependencies[key]; if (typeof dependency === 'string') { const tokens = dependency.split(':'); manifest.dependencies[key] = { name: tokens[0] || '*', version: tokens[1] || '*', required: true }; } }); } if (typeof manifest.controllers === 'string') { manifest.controllers = {'/': manifest.controllers}; } if (typeof manifest.tests === 'string') { manifest.tests = [manifest.tests]; } } } //////////////////////////////////////////////////////////////////////////////// /// @brief validates a manifest file and returns it. /// All errors are handled including file not found. Returns undefined if manifest is invalid //////////////////////////////////////////////////////////////////////////////// function validateManifestFile(file) { var mf, msg; if (!fs.exists(file)) { msg = `Cannot find manifest file "${file}"`; console.errorLines(msg); throwFileNotFound(msg); } try { mf = JSON.parse(fs.read(file)); } catch (err) { let details = String(err.stack || err); msg = `Cannot parse app manifest "${file}": ${details}`; console.errorLines(msg); throw new ArangoError({ errorNum: errors.ERROR_MALFORMED_MANIFEST_FILE.code, errorMessage: msg }); } try { checkManifest(file, mf); } catch (err) { console.errorLines(`Manifest file "${file}" is invalid:\n${err.errorMessage}`); if (err.stack) { console.errorLines(err.stack); } throw err; } return mf; } //////////////////////////////////////////////////////////////////////////////// /// @brief Checks if the mountpoint is reserved for system apps //////////////////////////////////////////////////////////////////////////////// function isSystemMount(mount) { return (/^\/_/).test(mount); } //////////////////////////////////////////////////////////////////////////////// /// @brief returns the root path for application. Knows about system apps //////////////////////////////////////////////////////////////////////////////// function computeRootAppPath(mount) { if (isSystemMount(mount)) { return FoxxService._systemAppPath; } return FoxxService._appPath; } //////////////////////////////////////////////////////////////////////////////// /// @brief transforms a mount point to a sub-path relative to root //////////////////////////////////////////////////////////////////////////////// function transformMountToPath(mount) { var list = mount.split('/'); list.push('APP'); return fs.join.apply(fs, list); } //////////////////////////////////////////////////////////////////////////////// /// @brief transforms a sub-path to a mount point //////////////////////////////////////////////////////////////////////////////// function transformPathToMount(path) { var list = path.split(fs.pathSeparator); list.pop(); return '/' + list.join('/'); } //////////////////////////////////////////////////////////////////////////////// /// @brief returns the application path for mount point //////////////////////////////////////////////////////////////////////////////// function computeAppPath(mount) { var root = computeRootAppPath(mount); var mountPath = transformMountToPath(mount); return fs.join(root, mountPath); } //////////////////////////////////////////////////////////////////////////////// /// @brief executes an app script //////////////////////////////////////////////////////////////////////////////// function executeAppScript(scriptName, app, argv) { var readableName = utils.getReadableName(scriptName); var scripts = app.manifest.scripts; // Only run setup/teardown scripts if they exist if (scripts[scriptName] || (scriptName !== 'setup' && scriptName !== 'teardown')) { try { return app.run(scripts[scriptName], { appContext: { argv: argv ? (Array.isArray(argv) ? argv : [argv]) : [] } }); } catch (e) { if (!(e.cause || e).statusCode) { let details = String((e.cause || e).stack || e.cause || e); console.errorLines(`Running script "${readableName}" not possible for mount "${app.mount}":\n${details}`); } throw e; } } } //////////////////////////////////////////////////////////////////////////////// /// @brief returns a valid app config for validation purposes //////////////////////////////////////////////////////////////////////////////// function fakeAppConfig(path) { var file = fs.join(path, 'manifest.json'); return { id: '__internal', root: '', path: path, options: {}, mount: '/internal', manifest: validateManifestFile(file), isSystem: false, isDevelopment: false }; } //////////////////////////////////////////////////////////////////////////////// /// @brief returns the app path and manifest //////////////////////////////////////////////////////////////////////////////// function appConfig(mount, options, activateDevelopment) { var root = computeRootAppPath(mount); var path = transformMountToPath(mount); var file = fs.join(root, path, 'manifest.json'); return { id: mount, path: path, options: options || {}, mount: mount, manifest: validateManifestFile(file), isSystem: isSystemMount(mount), isDevelopment: activateDevelopment || false }; } //////////////////////////////////////////////////////////////////////////////// /// @brief Creates an app with options and returns it /// All errors are handled including app not found. Returns undefined if app is invalid. /// If the app is valid it will be added into the local app cache. //////////////////////////////////////////////////////////////////////////////// function createApp(mount, options, activateDevelopment) { var dbname = arangodb.db._name(); var config = appConfig(mount, options, activateDevelopment); var app = new FoxxService(config); appCache[dbname][mount] = app; return app; } //////////////////////////////////////////////////////////////////////////////// /// @brief Distributes zip file to peer coordinators. Only used in cluster //////////////////////////////////////////////////////////////////////////////// function uploadToPeerCoordinators(appInfo, coordinators) { let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; let req = fs.readBuffer(fs.join(fs.getTempPath(), appInfo)); let httpOptions = {}; let mapping = {}; for (let i = 0; i < coordinators.length; ++i) { let ctid = ArangoClusterInfo.uniqid(); mapping[ctid] = coordinators[i]; coordOptions.clientTransactionID = ctid; ArangoClusterComm.asyncRequest('POST', 'server:' + coordinators[i], db._name(), '/_api/upload', req, httpOptions, coordOptions); } return { results: cluster.wait(coordOptions, coordinators), mapping: mapping }; } //////////////////////////////////////////////////////////////////////////////// /// @brief Generates an App with the given options into the targetPath //////////////////////////////////////////////////////////////////////////////// function installAppFromGenerator(targetPath, options) { var invalidOptions = []; // Set default values: options.name = options.name || 'MyApp'; options.author = options.author || 'Author'; options.description = options.description || ''; options.license = options.license || 'Apache 2'; options.authenticated = options.authenticated || false; options.collectionNames = options.collectionNames || []; if (typeof options.name !== 'string') { invalidOptions.push('options.name has to be a string.'); } if (typeof options.author !== 'string') { invalidOptions.push('options.author has to be a string.'); } if (typeof options.description !== 'string') { invalidOptions.push('options.description has to be a string.'); } if (typeof options.license !== 'string') { invalidOptions.push('options.license has to be a string.'); } if (typeof options.authenticated !== 'boolean') { invalidOptions.push('options.authenticated has to be a boolean.'); } if (!Array.isArray(options.collectionNames)) { invalidOptions.push('options.collectionNames has to be an array.'); } if (invalidOptions.length > 0) { console.log(invalidOptions); throw new ArangoError({ errorNum: errors.ERROR_INVALID_FOXX_OPTIONS.code, errorMessage: JSON.stringify(invalidOptions, undefined, 2) }); } options.path = targetPath; var engine = new TemplateEngine(options); engine.write(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Extracts an app from zip and moves it to temporary path /// /// return path to app //////////////////////////////////////////////////////////////////////////////// function extractAppToPath(archive, targetPath, noDelete) { var tempFile = fs.getTempFile('zip', false); fs.makeDirectory(tempFile); fs.unzipFile(archive, tempFile, false, true); // ............................................................................. // throw away source file // ............................................................................. if (!noDelete) { try { fs.remove(archive); } catch (err1) { arangodb.printf(`Cannot remove temporary file "${archive}"\n`); } } // ............................................................................. // locate the manifest file // ............................................................................. var tree = fs.listTree(tempFile).sort(function(a, b) { return a.length - b.length; }); var found; var mf = 'manifest.json'; var re = /[\/\\\\]manifest\.json$/; // Windows! var tf; var i; for (i = 0; i < tree.length && found === undefined; ++i) { tf = tree[i]; if (re.test(tf) || tf === mf) { found = tf; } } if (found === undefined) { throwFileNotFound(`Cannot find manifest file in zip file "${tempFile}"`); } var mp; if (found === mf) { mp = '.'; } else { mp = found.substr(0, found.length - mf.length - 1); } fs.move(fs.join(tempFile, mp), targetPath); // ............................................................................. // throw away temporary app folder // ............................................................................. if (found !== mf) { try { fs.removeDirectoryRecursive(tempFile); } catch (err1) { let details = String(err1.stack || err1); arangodb.printf(`Cannot remove temporary folder "${tempFile}"\n Stack: ${details}`); } } } //////////////////////////////////////////////////////////////////////////////// /// @brief builds a github repository URL //////////////////////////////////////////////////////////////////////////////// function buildGithubUrl(appInfo) { var splitted = appInfo.split(':'); var repository = splitted[1]; var version = splitted[2]; if (version === undefined) { version = 'master'; } var urlPrefix = require('process').env.FOXX_BASE_URL; if (urlPrefix === undefined) { urlPrefix = 'https://github.com/'; } return urlPrefix + repository + '/archive/' + version + '.zip'; } //////////////////////////////////////////////////////////////////////////////// /// @brief Downloads an app from remote zip file and copies it to mount path //////////////////////////////////////////////////////////////////////////////// function installAppFromRemote(url, targetPath) { var tempFile = fs.getTempFile('downloads', false); var auth; var urlObj = parseUrl(url); if (urlObj.auth) { auth = urlObj.auth.split(':'); auth = { username: decodeURIComponent(auth[0]), password: decodeURIComponent(auth[1]) }; delete urlObj.auth; url = formatUrl(urlObj); } try { var result = download(url, '', { method: 'get', followRedirects: true, timeout: 30, auth: auth }, tempFile); if (result.code < 200 || result.code > 299) { throwDownloadError(`Could not download from "${url}"`); } } catch (err) { let details = String(err.stack || err); throwDownloadError(`Could not download from "${url}": ${details}`); } extractAppToPath(tempFile, targetPath); } //////////////////////////////////////////////////////////////////////////////// /// @brief Copies an app from local, either zip file or folder, to mount path //////////////////////////////////////////////////////////////////////////////// function installAppFromLocal(path, targetPath) { if (fs.isDirectory(path)) { extractAppToPath(utils.zipDirectory(path), targetPath); } else { extractAppToPath(path, targetPath, true); } } // ----------------------------------------------------------------------------- // --SECTION-- public functions // ----------------------------------------------------------------------------- //////////////////////////////////////////////////////////////////////////////// /// @brief run a Foxx application script /// /// Input: /// * scriptName: the script name /// * mount: the mount path starting with a "/" /// /// Output: /// - //////////////////////////////////////////////////////////////////////////////// function runScript(scriptName, mount, options) { checkParameter( 'runScript(, , [])', [ [ 'Script name', 'string' ], [ 'Mount path', 'string' ] ], [ scriptName, mount ] ); var app = lookupApp(mount); return executeAppScript(scriptName, app, options) || null; } //////////////////////////////////////////////////////////////////////////////// /// @brief return the app's README.md /// /// Input: /// * mount: the mount path starting with a "/" /// /// Output: /// - //////////////////////////////////////////////////////////////////////////////// function readme(mount) { checkParameter( 'readme()', [ [ 'Mount path', 'string' ] ], [ mount ] ); let app = lookupApp(mount); let path, readmeText; path = fs.join(app.root, app.path, 'README.md'); readmeText = fs.exists(path) && fs.read(path); if (!readmeText) { path = fs.join(app.root, app.path, 'README'); readmeText = fs.exists(path) && fs.read(path); } return readmeText || null; } //////////////////////////////////////////////////////////////////////////////// /// @brief run a Foxx application's tests /// /// Input: /// * mount: the mount path starting with a "/" /// /// Output: /// - //////////////////////////////////////////////////////////////////////////////// function runTests(mount, options) { checkParameter( 'runTests(, [])', [ [ 'Mount path', 'string' ] ], [ mount ] ); var app = lookupApp(mount); var reporter = options ? options.reporter : null; return require('@arangodb/foxx/mocha').run(app, reporter); } //////////////////////////////////////////////////////////////////////////////// /// @brief Initializes the appCache and fills it initially for each db. //////////////////////////////////////////////////////////////////////////////// function initCache() { var dbname = arangodb.db._name(); if (!appCache.hasOwnProperty(dbname)) { initializeFoxx(); } } //////////////////////////////////////////////////////////////////////////////// /// @brief Internal scanFoxx function. Check scanFoxx. /// Does not check parameters and throws errors. //////////////////////////////////////////////////////////////////////////////// function _scanFoxx(mount, options, activateDevelopment) { options = options || { }; var dbname = arangodb.db._name(); delete appCache[dbname][mount]; var app = createApp(mount, options, activateDevelopment); if (!options.__clusterDistribution) { try { utils.getStorage().save(app.toJSON()); } catch (err) { if (!options.replace || err.errorNum !== errors.ERROR_ARANGO_UNIQUE_CONSTRAINT_VIOLATED.code) { throw err; } var old = utils.getStorage().firstExample({ mount: mount }); if (old === null) { throw new Error(`Could not find app for mountpoint "${mount}"`); } var manifest = app.toJSON().manifest; utils.getStorage().update(old, {manifest: manifest}); } } return app; } //////////////////////////////////////////////////////////////////////////////// /// @brief Scans the sources of the given mountpoint and publishes the routes /// /// TODO: Long Documentation! //////////////////////////////////////////////////////////////////////////////// function scanFoxx(mount, options) { checkParameter( 'scanFoxx()', [ [ 'Mount path', 'string' ] ], [ mount ] ); initCache(); var app = _scanFoxx(mount, options); reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Scans the sources of the given mountpoint and publishes the routes /// /// TODO: Long Documentation! //////////////////////////////////////////////////////////////////////////////// function rescanFoxx(mount) { checkParameter( 'scanFoxx()', [ [ 'Mount path', 'string' ] ], [ mount ] ); var old = lookupApp(mount); var collection = utils.getStorage(); initCache(); db._executeTransaction({ collections: { write: collection.name() }, action() { var definition = collection.firstExample({mount: mount}); if (definition !== null) { collection.remove(definition._key); } _scanFoxx(mount, old.options, old.isDevelopment); } }); } //////////////////////////////////////////////////////////////////////////////// /// @brief Build app in path //////////////////////////////////////////////////////////////////////////////// function _buildAppInPath(appInfo, path, options) { try { if (appInfo === 'EMPTY') { // Make Empty app installAppFromGenerator(path, options || {}); } else if (/^GIT:/i.test(appInfo)) { installAppFromRemote(buildGithubUrl(appInfo), path); } else if (/^https?:/i.test(appInfo)) { installAppFromRemote(appInfo, path); } else if (utils.pathRegex.test(appInfo)) { installAppFromLocal(appInfo, path); } else if (/^uploads[\/\\]tmp-/.test(appInfo)) { appInfo = fs.join(fs.getTempPath(), appInfo); installAppFromLocal(appInfo, path); } else { installAppFromRemote(store.buildUrl(appInfo), path); } } catch (e) { try { fs.removeDirectoryRecursive(path, true); } catch (err) { // noop } throw e; } } //////////////////////////////////////////////////////////////////////////////// /// @brief Internal app validation function /// Does not check parameters and throws errors. //////////////////////////////////////////////////////////////////////////////// function _validateApp(appInfo) { var tempPath = fs.getTempFile('apps', false); try { _buildAppInPath(appInfo, tempPath, {}); var tmp = new FoxxService(fakeAppConfig(tempPath)); if (!tmp.needsConfiguration()) { routeApp(tmp, true); exportApp(tmp); } } catch (e) { throw e; } finally { fs.removeDirectoryRecursive(tempPath, true); } } //////////////////////////////////////////////////////////////////////////////// /// @brief Internal install function. Check install. /// Does not check parameters and throws errors. //////////////////////////////////////////////////////////////////////////////// function _install(appInfo, mount, options, runSetup) { var targetPath = computeAppPath(mount, true); var app; var collection = utils.getStorage(); options = options || {}; if (fs.exists(targetPath)) { throw new Error('An app is already installed at this location.'); } fs.makeDirectoryRecursive(targetPath); // Remove the empty APP folder. // Ohterwise move will fail. fs.removeDirectory(targetPath); initCache(); _buildAppInPath(appInfo, targetPath, options); try { db._executeTransaction({ collections: { write: collection.name() }, action() { app = _scanFoxx(mount, options); } }); if (runSetup) { executeAppScript('setup', lookupApp(mount)); } if (!app.needsConfiguration()) { // Validate Routing routeApp(app, true); // Validate Exports exportApp(app); } } catch (e) { try { fs.removeDirectoryRecursive(targetPath, true); } catch (err) { console.errorLines(err.stack); } try { if (!options.__clusterDistribution) { db._executeTransaction({ collections: { write: collection.name() }, action() { var definition = collection.firstExample({mount: mount}); collection.remove(definition._key); } }); } } catch (err) { console.errorLines(err.stack); } if (e instanceof ArangoError) { if (e.errorNum === errors.ERROR_MODULE_SYNTAX_ERROR.code) { throw _.extend(new ArangoError({ errorNum: errors.ERROR_SYNTAX_ERROR_IN_SCRIPT.code, errorMessage: errors.ERROR_SYNTAX_ERROR_IN_SCRIPT.message }), {stack: e.stack}); } if (e.errorNum === errors.ERROR_MODULE_FAILURE.code) { throw _.extend(new ArangoError({ errorNum: errors.ERROR_FAILED_TO_EXECUTE_SCRIPT.code, errorMessage: errors.ERROR_FAILED_TO_EXECUTE_SCRIPT.message }), {stack: e.stack}); } } throw e; } return app; } //////////////////////////////////////////////////////////////////////////////// /// @brief Installs a new foxx application on the given mount point. /// /// TODO: Long Documentation! //////////////////////////////////////////////////////////////////////////////// function install(appInfo, mount, options) { checkParameter( 'install(, , [])', [ [ 'Install information', 'string' ], [ 'Mount path', 'string' ] ], [ appInfo, mount ] ); utils.validateMount(mount); let hasToBeDistributed = /^uploads[\/\\]tmp-/.test(appInfo); var app = _install(appInfo, mount, options, true); options = options || {}; if (ArangoServerState.isCoordinator() && !options.__clusterDistribution) { let name = ArangoServerState.id(); let coordinators = ArangoClusterInfo.getCoordinators().filter(function(c) { return c !== name; }); if (hasToBeDistributed) { let result = uploadToPeerCoordinators(appInfo, coordinators); let mapping = result.mapping; let res = result.results; let intOpts = JSON.parse(JSON.stringify(options)); intOpts.__clusterDistribution = true; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; let httpOptions = {}; for (let i = 0; i < res.length; ++i) { let b = JSON.parse(res[i].body); /*jshint -W075:true */ let intReq = {appInfo: b.filename, mount, options: intOpts}; /*jshint -W075:false */ ArangoClusterComm.asyncRequest('POST', 'server:' + mapping[res[i].clientTransactionID], db._name(), '/_admin/foxx/install', JSON.stringify(intReq), httpOptions, coordOptions); } cluster.wait(coordOptions, coordinators); } else { /*jshint -W075:true */ let req = {appInfo, mount, options}; /*jshint -W075:false */ let httpOptions = {}; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; req.options.__clusterDistribution = true; req = JSON.stringify(req); for (let i = 0; i < coordinators.length; ++i) { if (coordinators[i] !== ArangoServerState.id()) { ArangoClusterComm.asyncRequest('POST', 'server:' + coordinators[i], db._name(), '/_admin/foxx/install', req, httpOptions, coordOptions); } } } } reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Internal install function. Check install. /// Does not check parameters and throws errors. //////////////////////////////////////////////////////////////////////////////// function _uninstall(mount, options) { var dbname = arangodb.db._name(); if (!appCache.hasOwnProperty(dbname)) { initializeFoxx(options); } var app; options = options || {}; try { app = lookupApp(mount); } catch (e) { if (!options.force) { throw e; } } var collection = utils.getStorage(); var targetPath = computeAppPath(mount, true); if (!fs.exists(targetPath) && !options.force) { throw new ArangoError({ errorNum: errors.ERROR_NO_FOXX_FOUND.code, errorMessage: errors.ERROR_NO_FOXX_FOUND.message }); } delete appCache[dbname][mount]; if (!options.__clusterDistribution) { try { db._executeTransaction({ collections: { write: collection.name() }, action() { var definition = collection.firstExample({mount: mount}); collection.remove(definition._key); } }); } catch (e) { if (!options.force) { throw e; } } } if (options.teardown !== false && options.teardown !== 'false') { try { executeAppScript('teardown', app); } catch (e) { if (!options.force) { throw e; } } } try { fs.removeDirectoryRecursive(targetPath, true); } catch (e) { if (!options.force) { throw e; } } if (options.force && app === undefined) { return { simpleJSON() { return { name: 'force uninstalled', version: 'unknown', mount: mount }; } }; } return app; } //////////////////////////////////////////////////////////////////////////////// /// @brief Uninstalls the foxx application on the given mount point. /// /// TODO: Long Documentation! //////////////////////////////////////////////////////////////////////////////// function uninstall(mount, options) { checkParameter( 'uninstall(, [])', [ [ 'Mount path', 'string' ] ], [ mount ] ); utils.validateMount(mount); options = options || {}; var app = _uninstall(mount, options); if (ArangoServerState.isCoordinator() && !options.__clusterDistribution) { let coordinators = ArangoClusterInfo.getCoordinators(); /*jshint -W075:true */ let req = {mount, options}; /*jshint -W075:false */ let httpOptions = {}; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; req.options.__clusterDistribution = true; req.options.force = true; req = JSON.stringify(req); for (let i = 0; i < coordinators.length; ++i) { if (coordinators[i] !== ArangoServerState.id()) { ArangoClusterComm.asyncRequest('POST', 'server:' + coordinators[i], db._name(), '/_admin/foxx/uninstall', req, httpOptions, coordOptions); } } } reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Replaces a foxx application on the given mount point by an other one. /// /// TODO: Long Documentation! //////////////////////////////////////////////////////////////////////////////// function replace(appInfo, mount, options) { checkParameter( 'replace(, , [])', [ [ 'Install information', 'string' ], [ 'Mount path', 'string' ] ], [ appInfo, mount ] ); utils.validateMount(mount); _validateApp(appInfo); options = options || {}; let hasToBeDistributed = /^uploads[\/\\]tmp-/.test(appInfo); if (ArangoServerState.isCoordinator() && !options.__clusterDistribution) { let name = ArangoServerState.id(); let coordinators = ArangoClusterInfo.getCoordinators().filter(function(c) { return c !== name; }); if (hasToBeDistributed) { let result = uploadToPeerCoordinators(appInfo, coordinators); let mapping = result.mapping; let res = result.results; let intOpts = JSON.parse(JSON.stringify(options)); intOpts.__clusterDistribution = true; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; let httpOptions = {}; for (let i = 0; i < res.length; ++i) { let b = JSON.parse(res[i].body); /*jshint -W075:true */ let intReq = {appInfo: b.filename, mount, options: intOpts}; /*jshint -W075:false */ ArangoClusterComm.asyncRequest('POST', 'server:' + mapping[res[i].coordinatorTransactionID], db._name(), '/_admin/foxx/replace', JSON.stringify(intReq), httpOptions, coordOptions); } cluster.wait(coordOptions, coordinators); } else { let intOpts = JSON.parse(JSON.stringify(options)); /*jshint -W075:true */ let req = {appInfo, mount, options: intOpts}; /*jshint -W075:false */ let httpOptions = {}; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; req.options.__clusterDistribution = true; req.options.force = true; req = JSON.stringify(req); for (let i = 0; i < coordinators.length; ++i) { ArangoClusterComm.asyncRequest('POST', 'server:' + coordinators[i], db._name(), '/_admin/foxx/replace', req, httpOptions, coordOptions); } } } _uninstall(mount, {teardown: true, __clusterDistribution: options.__clusterDistribution || false, force: !options.__clusterDistribution }); var app = _install(appInfo, mount, options, true); reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Upgrade a foxx application on the given mount point by a new one. /// /// TODO: Long Documentation! //////////////////////////////////////////////////////////////////////////////// function upgrade(appInfo, mount, options) { checkParameter( 'upgrade(, , [])', [ [ 'Install information', 'string' ], [ 'Mount path', 'string' ] ], [ appInfo, mount ] ); utils.validateMount(mount); _validateApp(appInfo); options = options || {}; let hasToBeDistributed = /^uploads[\/\\]tmp-/.test(appInfo); if (ArangoServerState.isCoordinator() && !options.__clusterDistribution) { let name = ArangoServerState.id(); let coordinators = ArangoClusterInfo.getCoordinators().filter(function(c) { return c !== name; }); if (hasToBeDistributed) { let result = uploadToPeerCoordinators(appInfo, coordinators); let mapping = result.mapping; let res = result.results; let intOpts = JSON.parse(JSON.stringify(options)); intOpts.__clusterDistribution = true; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; let httpOptions = {}; for (let i = 0; i < res.length; ++i) { let b = JSON.parse(res[i].body); /*jshint -W075:true */ let intReq = {appInfo: b.filename, mount, options: intOpts}; /*jshint -W075:false */ ArangoClusterComm.asyncRequest('POST', 'server:' + mapping[res[i].coordinatorTransactionID], db._name(), '/_admin/foxx/update', JSON.stringify(intReq), httpOptions, coordOptions); } cluster.wait(coordOptions, coordinators); } else { let intOpts = JSON.parse(JSON.stringify(options)); /*jshint -W075:true */ let req = {appInfo, mount, options: intOpts}; /*jshint -W075:false */ let httpOptions = {}; let coordOptions = { coordTransactionID: ArangoClusterInfo.uniqid() }; req.options.__clusterDistribution = true; req.options.force = true; req = JSON.stringify(req); for (let i = 0; i < coordinators.length; ++i) { ArangoClusterComm.asyncRequest('POST', 'server:' + coordinators[i], db._name(), '/_admin/foxx/update', req, httpOptions, coordOptions); } } } var oldApp = lookupApp(mount); var oldConf = oldApp.getConfiguration(true); options.configuration = options.configuration || {}; Object.keys(oldConf).forEach(function (key) { if (!options.configuration.hasOwnProperty(key)) { options.configuration[key] = oldConf[key]; } }); var oldDeps = oldApp.options.dependencies || {}; options.dependencies = options.dependencies || {}; Object.keys(oldDeps).forEach(function (key) { if (!options.dependencies.hasOwnProperty(key)) { options.dependencies[key] = oldDeps[key]; } }); _uninstall(mount, {teardown: false, __clusterDistribution: options.__clusterDistribution || false, force: !options.__clusterDistribution }); var app = _install(appInfo, mount, options, true); reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief initializes the Foxx apps //////////////////////////////////////////////////////////////////////////////// function initializeFoxx(options) { var dbname = arangodb.db._name(); var mounts = syncWithFolder(options); refillCaches(dbname); checkMountedSystemApps(dbname); mounts.forEach(function (mount) { executeAppScript('setup', lookupApp(mount)); }); } //////////////////////////////////////////////////////////////////////////////// /// @brief compute all app routes //////////////////////////////////////////////////////////////////////////////// function mountPoints() { var dbname = arangodb.db._name(); return refillCaches(dbname); } //////////////////////////////////////////////////////////////////////////////// /// @brief toggles development mode of app and reloads routing //////////////////////////////////////////////////////////////////////////////// function _toggleDevelopment(mount, activate) { var app = lookupApp(mount); app.development(activate); utils.updateApp(mount, app.toJSON()); reloadRouting(); return app; } //////////////////////////////////////////////////////////////////////////////// /// @brief activate development mode //////////////////////////////////////////////////////////////////////////////// function setDevelopment(mount) { checkParameter( 'development()', [ [ 'Mount path', 'string' ] ], [ mount ] ); var app = _toggleDevelopment(mount, true); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief activate production mode //////////////////////////////////////////////////////////////////////////////// function setProduction(mount) { checkParameter( 'production()', [ [ 'Mount path', 'string' ] ], [ mount ] ); var app = _toggleDevelopment(mount, false); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Configure the app at the mountpoint //////////////////////////////////////////////////////////////////////////////// function configure(mount, options) { checkParameter( 'configure()', [ [ 'Mount path', 'string' ] ], [ mount ] ); utils.validateMount(mount, true); var app = lookupApp(mount); var invalid = app.applyConfiguration(options.configuration || {}); if (invalid.length > 0) { // TODO Error handling console.log(invalid); } utils.updateApp(mount, app.toJSON()); reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Set up dependencies of the app at the mountpoint //////////////////////////////////////////////////////////////////////////////// function updateDeps(mount, options) { checkParameter( 'updateDeps()', [ [ 'Mount path', 'string' ] ], [ mount ] ); utils.validateMount(mount, true); var app = lookupApp(mount); var invalid = app.applyDependencies(options.dependencies || {}); if (invalid.length > 0) { // TODO Error handling console.log(invalid); } utils.updateApp(mount, app.toJSON()); reloadRouting(); return app.simpleJSON(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Get the configuration for the app at the given mountpoint //////////////////////////////////////////////////////////////////////////////// function configuration(mount) { checkParameter( 'configuration()', [ [ 'Mount path', 'string' ] ], [ mount ] ); utils.validateMount(mount, true); var app = lookupApp(mount); return app.getConfiguration(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Get the dependencies for the app at the given mountpoint //////////////////////////////////////////////////////////////////////////////// function dependencies(mount) { checkParameter( 'dependencies()', [ [ 'Mount path', 'string' ] ], [ mount ] ); utils.validateMount(mount, true); var app = lookupApp(mount); return app.getDependencies(); } //////////////////////////////////////////////////////////////////////////////// /// @brief Require the exports defined on the mount point //////////////////////////////////////////////////////////////////////////////// function requireApp(mount) { checkParameter( 'requireApp()', [ [ 'Mount path', 'string' ] ], [ mount ] ); utils.validateMount(mount, true); var app = lookupApp(mount); return exportApp(app); } //////////////////////////////////////////////////////////////////////////////// /// @brief Syncs the apps in ArangoDB with the applications stored on disc //////////////////////////////////////////////////////////////////////////////// function syncWithFolder(options) { var dbname = arangodb.db._name(); options = options || {}; options.replace = true; appCache = appCache || {}; appCache[dbname] = {}; var folders = fs.listTree(FoxxService._appPath).filter(filterAppRoots); var collection = utils.getStorage(); return folders.map(function (folder) { var mount; db._executeTransaction({ collections: { write: collection.name() }, action() { mount = transformPathToMount(folder); _scanFoxx(mount, options); } }); return mount; }); } // ----------------------------------------------------------------------------- // --SECTION-- exports // ----------------------------------------------------------------------------- //////////////////////////////////////////////////////////////////////////////// /// @brief Exports //////////////////////////////////////////////////////////////////////////////// exports.syncWithFolder = syncWithFolder; exports.install = install; exports.readme = readme; exports.runTests = runTests; exports.runScript = runScript; exports.setup = _.partial(runScript, 'setup'); exports.teardown = _.partial(runScript, 'teardown'); exports.uninstall = uninstall; exports.replace = replace; exports.upgrade = upgrade; exports.development = setDevelopment; exports.production = setProduction; exports.configure = configure; exports.updateDeps = updateDeps; exports.configuration = configuration; exports.dependencies = dependencies; exports.requireApp = requireApp; exports._resetCache = resetCache; //////////////////////////////////////////////////////////////////////////////// /// @brief Serverside only API //////////////////////////////////////////////////////////////////////////////// exports.scanFoxx = scanFoxx; exports.mountPoints = mountPoints; exports.routes = routes; exports.rescanFoxx = rescanFoxx; exports.lookupApp = lookupApp; //////////////////////////////////////////////////////////////////////////////// /// @brief Exports from foxx utils module. //////////////////////////////////////////////////////////////////////////////// exports.mountedApp = utils.mountedApp; exports.list = utils.list; exports.listJson = utils.listJson; exports.listDevelopment = utils.listDevelopment; exports.listDevelopmentJson = utils.listDevelopmentJson; //////////////////////////////////////////////////////////////////////////////// /// @brief Exports from foxx store module. //////////////////////////////////////////////////////////////////////////////// exports.available = store.available; exports.availableJson = store.availableJson; exports.getFishbowlStorage = store.getFishbowlStorage; exports.search = store.search; exports.searchJson = store.searchJson; exports.update = store.update; exports.info = store.info; exports.initializeFoxx = initializeFoxx; // ----------------------------------------------------------------------------- // --SECTION-- END-OF-FILE // ----------------------------------------------------------------------------- /// Local Variables: /// mode: outline-minor /// outline-regexp: "/// @brief\\|/// @addtogroup\\|/// @page\\|// --SECTION--\\|/// @\\}\\|/\\*jslint" /// End: