diff --git a/lib/domain/schemas.js b/lib/domain/schemas.js index 220a797..c00cc65 100644 --- a/lib/domain/schemas.js +++ b/lib/domain/schemas.js @@ -154,6 +154,10 @@ const functionItem = { title: 'Hash', readOnly: true, }, + exposed: { + type: 'bool', + title: 'Exposed on HTTP Invocator', + }, versionID: { type: 'string', title: 'Version ID', diff --git a/lib/domain/storage/redis.js b/lib/domain/storage/redis.js index 37fb0a4..4cef721 100644 --- a/lib/domain/storage/redis.js +++ b/lib/domain/storage/redis.js @@ -208,6 +208,12 @@ class StorageRedis extends Storage { data.env = JSON.stringify(code.env); } + if (code.exposed === true) { + data.exposed = 'true'; + } else if (code.exposed === false) { + data.exposed = 'false'; + } + this.setNamespaceMember(namespace, id); return this.client.hmset(key, data); } @@ -233,6 +239,7 @@ class StorageRedis extends Storage { updated: data.updated, namespaceSettings, versionID: data.versionID || null, + exposed: data.exposed === 'true', }; if (data.env) { diff --git a/lib/http/routers/FunctionsPublicRouter.js b/lib/http/routers/FunctionsPublicRouter.js new file mode 100644 index 0000000..9d0e1c2 --- /dev/null +++ b/lib/http/routers/FunctionsPublicRouter.js @@ -0,0 +1,16 @@ +const Router = require('express').Router; +const bodyParser = require('body-parser'); + +const functionRunHandler = require('./functionRunHandler'); + +const router = new Router(); +const { bodyParserLimit } = require('../../support/config'); + + +router.all( + '/:namespace/:id', + bodyParser.json({ limit: bodyParserLimit }), + (req, res) => functionRunHandler(req, res, { exposed: true }) +); + +module.exports = router; diff --git a/lib/http/routers/FunctionsRouter.js b/lib/http/routers/FunctionsRouter.js index ec8985a..e1c41b4 100644 --- a/lib/http/routers/FunctionsRouter.js +++ b/lib/http/routers/FunctionsRouter.js @@ -4,14 +4,14 @@ const Router = require('express').Router; const bodyParser = require('body-parser'); const Validator = require('jsonschema').Validator; +const functionRunHandler = require('./functionRunHandler'); +const FunctionsRequest = require('../FunctionsRequest'); +const SchemaResponse = require('../SchemaResponse'); + const log = require('../../support/log'); const schemas = require('../../domain/schemas'); const Pipeline = require('../../domain/Pipeline'); -const ErrorTracker = require('../../domain/ErrorTracker'); -const { StdoutLogStorage, DefaultLogStorage } = require('../../domain/LogStorage'); -const FunctionsRequest = require('../FunctionsRequest'); -const Metric = require('../../domain/Metric'); -const SchemaResponse = require('../SchemaResponse'); +const { StdoutLogStorage } = require('../../domain/LogStorage'); const router = new Router(); const { bodyParserLimit } = require('../../support/config'); @@ -119,6 +119,7 @@ router.put('/:namespace/:id', bodyParser.json({ limit: bodyParserLimit }), async const { code, env, + exposed, } = req.body; const filename = codeFileName(namespace, id); const sandbox = req.app.get('sandbox'); @@ -139,6 +140,10 @@ router.put('/:namespace/:id', bodyParser.json({ limit: bodyParserLimit }), async data.env = env; } + if (exposed !== undefined) { + data.exposed = exposed; + } + try { await memoryStorage.putCode(namespace, id, data); res.set({ ETag: data.hash }); @@ -241,85 +246,11 @@ router.delete('/:namespace/:id', async (req, res) => { }); -router.all('/:namespace/:id/run', bodyParser.json({ limit: bodyParserLimit }), async (req, res) => { - const { namespace, id } = req.params; - const memoryStorage = req.app.get('memoryStorage'); - const sandbox = req.app.get('sandbox'); - const filename = codeFileName(namespace, id); - const metric = new Metric('function-run'); - const logStorage = new DefaultLogStorage(namespace, id, req); - - let code; - - try { - code = await memoryStorage.getCodeByCache(namespace, id, { - preCache: (preCode) => { - preCode.script = sandbox.compileCode(filename, preCode.code); - return preCode; - }, - }); - - if (!code) { - const error = new Error(`Code '${namespace}/${id}' is not found`); - error.statusCode = 404; - throw error; - } - } catch (err) { - res.status(err.statusCode || 500).json({ error: err.message }); - return; - } - - try { - const options = { - console: logStorage.console, - env: code.env, - }; - const result = await sandbox.runScript(code.script, req, options); - - res.set(result.headers); - res.status(result.status); - res.json(result.body); - - const spent = metric.finish({ - filename, - status: result.status, - }); - - logStorage.flush({ - status: result.status, - requestTime: spent, - }); - } catch (err) { - logStorage.console.error(`Failed to run function: ${err}`); - logStorage.console.error(err.stack); - const status = err.statusCode || 500; - res.status(status).json({ error: err.message }); - - const spent = metric.finish({ - filename, - status, - error: err.message, - }); - - const logResult = logStorage.flush({ - status, - requestTime: spent, - }); - - const { namespaceSettings } = code; - const { sentryDSN } = namespaceSettings || {}; - - const extra = Object.assign({ body: req.body }, logResult || {}); - const errTracker = new ErrorTracker({ - sentryDSN, - filename, - extra, - tags: { codeHash: code.hash }, - code: code.code, - }); - errTracker.notify(err); - } -}); +router.all( + '/:namespace/:id/run', + bodyParser.json({ limit: bodyParserLimit }), + (req, res) => functionRunHandler(req, res, { exposed: false }) +); router.put('/pipeline', bodyParser.json({ limit: bodyParserLimit }), async (req, res) => { diff --git a/lib/http/routers/functionRunHandler.js b/lib/http/routers/functionRunHandler.js new file mode 100644 index 0000000..41530b9 --- /dev/null +++ b/lib/http/routers/functionRunHandler.js @@ -0,0 +1,96 @@ +const ErrorTracker = require('../../domain/ErrorTracker'); +const Metric = require('../../domain/Metric'); +const { DefaultLogStorage } = require('../../domain/LogStorage'); + + +function codeFileName(namespace, codeId) { + return `${namespace}/${codeId}.js`; +} + + +async function functionRunHandler(req, res, { exposed }) { + const { namespace, id } = req.params; + const memoryStorage = req.app.get('memoryStorage'); + const sandbox = req.app.get('sandbox'); + const filename = codeFileName(namespace, id); + const metric = new Metric('function-run'); + const logStorage = new DefaultLogStorage(namespace, id, req); + + let code; + + try { + code = await memoryStorage.getCodeByCache(namespace, id, { + preCache: (preCode) => { + preCode.script = sandbox.compileCode(filename, preCode.code); + return preCode; + }, + }); + + if (!code) { + const error = new Error(`Code '${namespace}/${id}' is not found`); + error.statusCode = 404; + throw error; + } + if (exposed && !code.exposed) { + const error = new Error('Unauthorized'); + error.statusCode = 403; + throw error; + } + } catch (err) { + res.status(err.statusCode || 500).json({ error: err.message }); + return; + } + + try { + const options = { + console: logStorage.console, + env: code.env, + }; + const result = await sandbox.runScript(code.script, req, options); + + res.set(result.headers); + res.status(result.status); + res.json(result.body); + + const spent = metric.finish({ + filename, + status: result.status, + }); + + logStorage.flush({ + status: result.status, + requestTime: spent, + }); + } catch (err) { + logStorage.console.error(`Failed to run function: ${err}`); + logStorage.console.error(err.stack); + const status = err.statusCode || 500; + res.status(status).json({ error: err.message }); + + const spent = metric.finish({ + filename, + status, + error: err.message, + }); + + const logResult = logStorage.flush({ + status, + requestTime: spent, + }); + + const { namespaceSettings } = code; + const { sentryDSN } = namespaceSettings || {}; + + const extra = Object.assign({ body: req.body }, logResult || {}); + const errTracker = new ErrorTracker({ + sentryDSN, + filename, + extra, + tags: { codeHash: code.hash }, + code: code.code, + }); + errTracker.notify(err); + } +} + +module.exports = functionRunHandler; diff --git a/lib/http/routes.js b/lib/http/routes.js index f7387ed..c24dfa0 100644 --- a/lib/http/routes.js +++ b/lib/http/routes.js @@ -1,4 +1,5 @@ const express = require('express'); +const vhost = require('vhost'); const morgan = require('morgan'); const Sandbox = require('@globocom/backstage-functions-sandbox'); @@ -10,6 +11,7 @@ const StatusRouter = require('./routers/StatusRouter'); const DebugRouter = require('./routers/DebugRouter'); const NamespacesRouter = require('./routers/NamespacesRouter'); const FunctionsRouter = require('./routers/FunctionsRouter'); +const FunctionsPublicRouter = require('./routers/FunctionsPublicRouter'); const RedisStorage = require('../domain/storage/redis'); const parseExposeEnv = require('../support/parseExposeEnv'); const config = require('../support/config'); @@ -26,19 +28,24 @@ morgan.token('response-sectime', (req, res) => { return secs.toFixed(3); }); -const app = express(); - -app.use(morgan(config.log.morganFormat)); -app.disable('x-powered-by'); -app.enable('trust proxy'); - -app.set('memoryStorage', new RedisStorage()); -app.set('sandbox', new Sandbox({ +const memoryStorage = new RedisStorage(); +const sandbox = new Sandbox({ env: parseExposeEnv(), globalModules: config.defaultGlobalModules, asyncTimeout: config.asyncTimeout, syncTimeout: config.syncTimeout, -})); +}); + +function setupApp(app) { + app.use(morgan(config.log.morganFormat)); + app.disable('x-powered-by'); + app.enable('trust proxy'); + app.set('memoryStorage', memoryStorage); + app.set('sandbox', sandbox); +} + +const app = express(); +setupApp(app); app.get('/', (req, res) => { const backstageRequest = new FunctionsRequest(req); @@ -49,6 +56,17 @@ app.get('/', (req, res) => { }); }); +if (config.exposed.host) { + const publicApp = express(); + setupApp(publicApp); + publicApp.use(FunctionsPublicRouter); + publicApp.use((req, res) => { + res.send({ error: 'Not found' }); + }); + + app.use(vhost(config.exposed.host, publicApp)); +} + app.use('/healthcheck', HealthcheckRouter); app.use('/status', StatusRouter); app.use('/_debug', DebugRouter); diff --git a/lib/support/config.js b/lib/support/config.js index 1d9c99a..7ac646c 100644 --- a/lib/support/config.js +++ b/lib/support/config.js @@ -37,4 +37,7 @@ module.exports = { useCertFile: ConfigDiscovery.getBool('FUNCTIONS_USE_SSL_CERT_FILE', false), certFile: process.env.SSL_CERT_FILE, }, + exposed: { + host: process.env.EXPOSED_HOST, + }, }; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7611bfa..7491b68 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -7221,6 +7221,11 @@ "extsprintf": "1.0.2" } }, + "vhost": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vhost/-/vhost-3.0.2.tgz", + "integrity": "sha1-L7HezUxGaqiLD5NBrzPcGv8keNU=" + }, "which": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", diff --git a/package.json b/package.json index e8d288a..2f95b27 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "request": "^2.81.0", "stack-trace": "^0.0.9", "uuid": "^3.0.1", + "vhost": "^3.0.2", "winston": "^2.2.0" }, "devDependencies": { diff --git a/test/integration/domain/storage/storage-redis.test.js b/test/integration/domain/storage/storage-redis.test.js index 70d8adf..9f364b9 100644 --- a/test/integration/domain/storage/storage-redis.test.js +++ b/test/integration/domain/storage/storage-redis.test.js @@ -40,6 +40,7 @@ describe('StorageRedis', () => { CLIENT_ID: 'my client id', MY_VAR: 'my var', }, + exposed: true, }; const x = await storage.putCode('backstage', 'test', code); expect(x).to.be.eql('OK'); @@ -53,6 +54,7 @@ describe('StorageRedis', () => { expect(code2.versionID).to.match(UUID_REGEX); expect(code2.env.CLIENT_ID).to.be.eql('my client id'); expect(code2.env.MY_VAR).to.be.eql('my var'); + expect(code2.exposed).to.be.eql(true); }); it('should have a created equal updated for new function', async () => {