As an alternative to writing integrations that interface with MBEE via the REST API, the MBEE Core Framework (MCF) supports plugins. Plugins are server-side extensions of the MCF that allow integrations to add new API endpoints, create additional views, and directly interface with MBEE data using the modules built in to the the core framework.
Because plugins are server-side and execute code on the MBEE server, they introduce additional security risks that must be carefully evaluated before being run. As extensions of the Core Framework, plugins can introduce any vulnerability that could exist in the core framework such as cross-site scripting, authentication bypass, data exposure, and more. It is critical that these risks be mitigated by vetting plugins with the same care and scrutiny that is used to assess the core framework.
As server-side extensions of the Core Framework, plugins must also be written in Node.js. The only interface required of a plugin is that the plugin's main file (i.e. the file described by the main field in the plugin's package.json file) must export an Express.js application.
Perhaps the simplest way to describe how to create a plugin is to simply walk through an example. This section walks through creating a simple plugin that adds a single route that returns the string "Hello World!"
First, create an empty directory for your plugin. Inside that directory, run
npm init
and enter the information for your plugin. The most important field
is the entry point or main script. This is what MBEE looks for to know how
to execute your plugin.
Now create a new file that matches the name you provided as the entry point. The
default entry point name for NPM is index.js
, so that's what we'll use here.
Now in your index.js
file, add the following code:
// Requires express module
const express = require('express');
// Instantiates an express application
const app = express();
// Define our "Hello World" route
app.get('/', function(req, res){
res.send('Hello world!')
});
// Export/expose the app
module.exports = app;
To install plugins on an MBEE instance, the plugin must be specified in the MBEE config file. This information is then used by MBEE to load plugins when the server is started.
To specify a plugin, a "plugins" section must be provided within the "server" section of the config. To add your plugin to your MBEE config, simply add it as an object to the plugins section:
{
"server": {
"plugins": {
"enabled": true,
"plugins": {
"hello-plugin": {
"name": "hello-plugin",
"source": "/path/to/your/plugin/directory",
"title": "Hello World"
}
}
}
}
}
You also need to set the enabled field to true
to enable plugins. Once
configured, start up your MBEE server and browse to /plugins/hello-plugin
.
You should see a response of "Hello World".
In the server.plugins
section, a list of plugins can be specified.
Each plugin object defines a plugin and must include the following
key-value pairs.
{
"plugin-name": {
"title": "Hello World",
"name": "hello-plugin",
"source": "https://github.com/lmco/mbee-plugins-hello-world.git"
}
}
The title
field defines a user-friendly title for the plugin.
The name
field uniquely identifies the plugin (i.e. two plugins cannot be
defined with the same name). The name defines the plugin's namespace. It should
only contain lowercase letters and numbers (no whitespace or special
characters should be used). The name
field should match the name of the
plugin object.
When a plugin gets loaded into the MCF, it is used by MCF within a namespace.
MCF clones the plugin into a directory based on the name and namespaces the
plugin's routes under /plugins/<name>
. This means that if a plugin named
"myplugin" defines a route called /hello
, that route will be loaded into the
MCF as /plugins/myplugin/hello
. This provides each plugin with their own unique
namespace so developers can define routes and APIs without worrying about
overlap.
The source
field tells the MCF where to get the plugin. If the source begins
with ./
or /
it knows to get the plugin from the local filesystem. If the
source is a URL ending in .git
, the plugin is cloned from a Git repository.
SSH can also be used for Git repositories (see deployKey
below). Finally,
if a URL is provided that ends in .zip
, .gz
, or .tar.gz
, the MCF will
download and unzip the source code from the corresponding archive.
An SSH key can be specified with the deployKey
field. This field should
contain a path to the Git deploy key (relative to the MBEE root directory).
Plugins can also run tests upon startup of the server. This can be specified
with the testOnStartup
field. Plugin testing is explained in further detail
below.
Plugins can also define their own configuration variables inside the MBEE
config. Inside the server.plugins
section, key/value pairs can be added a key
with the same name as the plugin. For example if the helloworld plugin had
configuration options, the section server.plugins.helloworld
could be added to
the config and inside that, the configuration options could be specified.
The example below shows a few different ways to specify plugins.
{
"server": {
"plugins": {
"enabled": true,
"plugins": {
"myplugin1": {
"title": "My Plugin 1",
"name": "myplugin1",
"source": "https://example.com/my-plugin-1-repo.git",
"key": "some configuration option",
"testOnStartup": true
},
"myplugin2": {
"title": "My Plugin 2",
"name": "myplugin2",
"source": "[email protected]:my-plugin-2-repo.git",
"deployKey": "./keys/my-plugin-2-deploy-key.key"
},
"myplugin3": {
"title": "My Plugin 3",
"name": "myplugin3",
"source": "./plugins/myplugin3",
"testOnStartup": true
},
"myplugin4": {
"title": "My Plugin 4",
"name": "myplugin4",
"source": "https://example.com/path/to/my-plugin-4.tar.gz"
}
}
}
}
}
When plugins are initially loaded upon startup of the MCF, they will also be built. This includes running yarn install on the plugins' specified package.json files to install any additional dependencies that plugin may have, as well as running the plugins' build scripts, if they exist.
These plugin dependencies are installed in a node_modules directory within the specific plugin directory. One advantage of this approach is that a plugin may require a different version of a same dependency that the MCF uses. Having separate directories allow the MCF and plugin to use different versions of the same package without conflict.
A build script can be specified for several reasons, most notably to generate and/or load pages and scripts for the UI, as the MCF itself must do.
Much like the core codebase, the MCF supports Mocha.js tests for plugins. In order
for a plugin's tests to be recognized, they must be placed in a directory named test
at the root directory of the plugin. They can also reside in subfolders within the
test
directory, as the MCF performs a recursive search. Note that the MCF will
attempt to run EVERY javascript file found within the test
directory as a test.
The testing script, discussed further in section 6.0, can take the arguments
--plugin
{pluginName}
to run that plugin's tests instead of the normal tests.
Alternatively, the script can also be run with the argument --plugins
to run the
tests of every installed plugin that includes a test
directory.
When starting the MCF server and iterating through the installation of every plugin,
there is an option to run plugin-specific tests on each plugin as the final part of
the installation process. If any of the tests fail, the server will exit. This is
specified with the testOnStartup
field in each plugin object in the config file.
(New in 2.0.0)
As mentioned before, plugins do not persist in the MCF runtime, when the server
restarts it deletes and re-initializes/builds all plugins. While this works as
a CM and configuration consistant approach it is less than desirable for those who
prefer a faster startup and/or those who are pulling the plugins from a Git repo.
Therefore, in order to allow persistance of plugins a persistToPath
can be specified
on a per-plugin basis to copy any cloned plugins to another folder.
{
"server": {
"plugins": {
"enabled": true,
"plugins": {
"myplugin1": {
"title": "My Plugin 1",
"name": "myplugin1",
"source": "https://example.com/my-plugin-1-repo.git",
"persistToPath": "./all_plugins"
}
}
}
}
}
Note: All relative paths specified in `persistToPath` will be sourced from the mcf home
directory (`/opt/mbee`).
This method is leveraged by the official Dockerfile which would allow a plugin to be cloned
from a git repo and then copied to ./all_plugins
Plugins this folder then can be persisted
via a Docker Volume. Persistance can be achieved by starting up the container, allowing the
server to start up and clone
the plugins then stopping the service, and amending the config
file to use the local directory.
Plugin Config Before/Cold Start:
"myplugin1": {
"title": "My Plugin 1",
"name": "myplugin1",
"source": "https://example.com/my-plugin-1-repo.git",
"persistToPath": "./all_plugins"
}
Plugin Config After/Hot Start:
"myplugin1": {
"title": "My Plugin 1",
"name": "myplugin1",
"source": "./all_plugins/myplugin1",
"persistToPath": "false"
}
Plugins have the option of registering synchronous middleware with the MCF API.
This can be used in cases where a team may wish to modify data before or after it
reaches the controllers, or even integrate with other platforms. For example,
to integrate with an external system such as Teamwork Cloud, one may wish to validate
element CRUD operations against the TWC server. Webhooks could be used to notify
the external server when such an operation occurs. However, because webhooks do not
listen for a response, a failure on the external server would not be communicated to
the MCF, leading to inconsistent data. With synchronous middleware, an operation can
be validated externally first, and then be allowed to proceed. If the operation fails
within the controller, the post-controller middleware can notify the external server.
There are a few API Controller functions that do not have middleware capability; most
notably the login
and patchPassword
functions for security reasons.
All permitted API routes are structured as follows:
Middleware.pluginPre('exampleFunction'),
APIController.exampleFunction,
Middleware.pluginPost('exampleFunction')
In order to take advantage of the middleware capabilities, a plugin must include a "middleware.js" module in its root directory. This module must export objects with the names of their target api function and with the keys "pre" and/or "post". The "pre" and "post" key values must be functions that accept a Express Request and Response objects as inputs. For example:
module.exports.exampleFunction = {
pre: function(req, res) {
// Some logic to process data pre-exampleFunction
},
post: function(req, res) {
// Some logic to process data post-exampleFunction
}
}
Any export described in a plugin's middleware.js module that does not follow these rules is ignored upon installation and causes a warning to be logged to the console.
The most immediate benefit of extending the MCF through plugins is that plugin developers can leverage all of the underlying capability and modules of the Core Framework. There are however some recommended practices which will be covered in this section.
For a full detailed discussion of the Core Framework and the modules within it,
see the next section of this document, 6.0 Core Framework
. This guide is not
intended to dive into that level of detail, but instead to discuss some best
practices and give a brief introduction to the Core Framework for plugin
developers.
It is also important to understand that as a plugin developer you are extending the underlying capabilities of the MCF. This means that your code can introduce vulnerabilities to MBEE the same way any code in the Core Framework can. It is the responsibility of plugin developers and system administrators to carefully review plugins before deploying them into production environments.
The MBEE Core Framework (MCF) is build on an MVC (model, view, controller)
architecture and the structure of the source code closely resembles this.
However, the MCF provides a global object that is accessible to all plugins
without requiring any modules. This object is called the M
object and is
accessible through a global variable called M
. This object is read-only and
provides a number of useful utilities for plugin developers.
The first and most useful is the M.require
function. This function is a
wrapper around Node's built-in require function that allows import of MBEE
modules without using relative paths. For example, the directory structure of
the MCF looks something like this (roughly):
+ /
+ app/
+ controllers/
+ element-controller.js
+ lib/
+ utils.js
+ models/
+ element-model.js
+ views/
+ config/
+ doc/
+ plugins/
+ scripts/
+ test/
mbee.js
package.json
The M.require
gives you a mechanism for requiring Core Framework modules
without needing to figure out the relative path of the module and the current
executing directory. M.require
takes a single string and assumes that path is
relative to the app
directory.
This means that regardless of your plugin structure, anywhere in your plugin
code you can use M.require
to load a module. The example below shows the
loading of the element controller.
const elementController = M.require('controllers.element-controller');
One thing to note about the example above is that we did not use a slash between
controllers
and element-controller
. The M.require
function allows .
to
be used which will then be replaced with the OS-specific separator
automatically. Slashes may be used, but .
is preferable.
Another useful property on the M
object is M.root
. This gives the full path
of the root directory of the MBEE Core Framework. This is useful if you ever
need to load a file or module that is not in the main app directory.
The configuration file for MBEE is loaded into the M
object at startup. This
means that plugins can access the full configuration without having to find
and load the config file. This also allows plugin developers to define new
configuration fields.
Nothing needs to be done to define a new field, simply document what must be
there in your plugin's documentation and look for it on the M.config
object.
You should however have some mechanism for safely failing if the configuration item is not found. One way to approach this is to verify that all configuration items that are needed can be found when the app is defined and throw an error if the plugin should fail. Plugins are loaded synchronously and errors in the synchronous loading are easily caught by the MCF. If an error is caught by MCF during this loading, MCF can safely disable the plugin to avoid unexpected behavior.
The M.version
property contains a string that specifies the MCF version
number. This is useful if your plugin is only compatible with certain versions
of MBEE.
The MCF logger is also provided as part of the M
object. This logger object is
based on Winston. Rather than using
console.log
statements, plugin developers can use the following functions:
M.log.critical('Critical! Something very bad happened!');
M.log.error('Error! Something went wrong.');
M.log.warn('Warning! Something not great happened, but it may be okay.');
M.log.info('This is informational output.');
M.log.verbose('This is for if more detail is needed.');
M.log.debug('This is a debug statement.');
The key modules that will be useful to plugin developers are the model and controller modules. The models define the data models for interacting with the database. The controllers provide the business logic on top of the models. While the models handle and define the data and core objects in MCF, the controllers implement the behavior for those objects. For that reason, it is highly recommended that controllers be used, not models, whenever possible and that models only be used directly when absolutely necessary.
Each of the main data objects in MBEE has a model and a controller. The models
are found in the app/model
directory and the module name is the name of the
object. For example, the elements are defined in the model element.js
.
Each of these models has a corresponding controller found in the
app/controllers
directory. These modules are named using the following format:
<object>-controller.js
. For example, the element controller is defined in a
module named element-controller.js
.
Most of these controllers provide methods for CRUD (create, read, update, delete) operations on these objects. For example, the element controller can be used to find an element with given data:
- ID:
model
- BRANCH:
master
- PROJECT:
example
- ORGANIZATION:
default
const elementController = M.require('controllers.element-controller');
elementController.find(req.user, 'default', 'example', 'master', 'model')
.then(element => console.log(element.name))
.catch(err => console.error(err));
For more information see section 6.0 Core Framework
of this document or view
the JSDoc documentation by navigating to the /doc/developers
route of MBEE.
The Core Framework provides a collection of libraries in the app/lib
folder
that provide useful functionality for a variety of MBEE actions including event
messaging, authentication, data validation, and much more.
For more information see section 6.0 Core Framework
of this document or view
the JSDoc documentation by navigating to the /doc/developers
route of MBEE.
Adding authentication to plugin routes is quite simple. Simply import the
authenticate
function from the auth
library and use it as middleware on a
route.
For example, to have a simple plugin with an authenticated route, your app may look like this:
// Initialize an express app
const express = require('express');
const app = express();
// Require the authentication module
const {authenticate} = M.require('lib.auth');
// Define an authenticated route
app.get('/', authenticate, (req, res) => res.send('Hello world!'));
// Export the app
module.exports = app;
Adding plugins to the user interface can be done on a project to project basis. When a plugin has been loaded into MBEE, a user can add custom data to a project with the following json:
{
"custom": {
"integrations": [
{
// Integration id
"name": "<Integration Name>",
// Display name
"title": "<Integration Title>",
// Plugin route
"url": "<Integration URL>",
// Plugin icon
"icon": "<Integration icon>",
// Boolean for opening in a new tab in browser
"openNewTab": <true/false>
}
]
}
}
The icon will be a font awesome icon that you can look up at FontAwesome. The icon and openNewTab are not required and will be set by default. The default for the openNewTab is false.
This tutorial gave a brief introduction to plugins through the development of a simple "Hello World" plugin. This was not meant to be a particularly robust or high-quality plugin, but to begin to shed some light on what a plugin can do.
For more in-depth examples, please see tutorial Tutorial 004 Mass Plugin
in the flight
manual.