Skip to content

Latest commit

 

History

History
526 lines (434 loc) · 20.1 KB

5.0-plugins.md

File metadata and controls

526 lines (434 loc) · 20.1 KB

Plugins

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.

Example: A Hello World Plugin

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".

Installing Plugins and Configuration

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"
        }
      }
    }
  }
}

Building Plugins

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.

Testing Plugins

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.

Plugin Persistance

(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"
}

Synchronous Middleware

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.

Using the Core Framework Modules

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.

M.require

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.

M.root

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.

M.config

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.

M.version

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.

M.log

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.');

Models and Controllers

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.

Libraries

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.

Authentication

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;

UI Feature

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.

Summary

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.