Creating an iControl Extension

The BIG-IP system and iWorkflow provide a service named restnoded. This service runs as a Node.js daemon that enables you to write your extension source files using the JavaScript language, with access to Node.js functionality and F5 provided utilities. The restnoded service is also compatible with iControl REST to enable you to create extensions that use REST end-points.

By configuring your extension to listen on a URI, it can respond to standard events (described below) allowing you to decide how your iControl LX extensions will respond to the 5 standard REST verbs: GET, POST, PUT, PATCH, and DELETE.

Your extension will have access to F5 modules and, by implementing a few simple events or walking through the sample below, your extension will be able to respond to external and internal REST requests executing appropriate business logic.

Prerequisites and Requirements

The iControl LX framework is based on JavaScript which runs in the Node.js context, so experience in these technologies (or code samples for those languages) is needed. You should also be familiar with the API lifecycle.

System Requirements include:

  • A configured standalone (or clustered) BIG-IP version 12.0.x device, or above. Or, an iWorkflow v2.3.0 platform,
  • ssh access to the BIG-IP/iWorkflow, and the ability to communicate to it over port 22.
  • For testing purposes, you will need HTTPS access (port 443) from your BIG-IP/iWorkflow.

You also need to follow these general guidelines whenever using the F5 BIG-IP/iWorkflow REST API.

  • Use an account with correct administrator or tenant privileges.
  • Send all HTTPS requests to port 443.
  • Each REST API call must include either Basic or Token-Based Authentication.
  • Include the required header with each call to the REST API: - Content-Type: application/json - Used with POST, PUT, PATCH, and DELETE (if supplying a payload)

Development Environment and Process

The runtime for iControl LX extensions will be on your F5 BIG-IP or iWorkflow system. In most cases, you can write and manage the source code for your extension in your code editor of choice, but it must be copied to the F5 system for packaging and deployment.

For the purpose of this walkthrough, it is assumed that you are writing your extension on a development workstation and deploying it on a BIG-IP/iWorkflow machine reserved for development purposes (not used in production).

Follow these steps to create, deploy, and test your extension:

  1. Write and edit your extension source.
  2. Package and deployment:
    • Copy extension and relevant source files to an F5 system
    • Build your iControl extension distribution package
    • Install the distribution package on an F5 system

These steps are detailed in Creating a Distribution iControl LX/iApps LX RPM Package and Working With iControl LX/iApps LX RPM Packages.

Writing Your Extension

We will use the supplied sample extension SkeletonWorker.js as a starting point and walk you through the changes needed to customize it. The SkeletonWorker class illustrates the iControl LX extension pattern supported by the iControl LX framework.

Note

You can also access the sample extension, SkeletonWorker, from your BIG-IP file system (/usr/share/rest/node/src/workers/skeletonWorker.js)

In this example, we will be creating an extension called MemoryWorker that remembers values passed in and returns them to the caller of a GET request.

Create MemoryWorker.js from SkeletonWorker.js

Copy the SkeletonWorker.js to your development system and name that file MemoryWorker.js.

Change all of the references from SkeletonWorker to MemoryWorker in your file and save it. A simple vi command to do this string replacement is:

:%s/SkeletonWorker/MemoryWorker/g

At this point your extension is named properly. We will now look at the various components to allow you to customize its behavior.

Overview of MemoryWorker Components

function MemoryWorker()

function MemoryWorker() {
    this.state = {};
}

The constructor is called at extension load time and you can add code in it to perform any needed initialization for your extension. By convention, this.state will contain the (ephemeral) state of the object. Without further code, iControl extensions are stateless which means that it’s current state will not be preserved on system restarts, restnoded restarts, or other failover scenarios. Stateless workers are appropriate when they interact with other components or API’s that are relied on for state persistence. If your API needs to preserve its own state durably than you will need to enable persistence in your worker.

WORKER_URI_PATH

MemoryWorker.prototype.WORKER_URI_PATH = "extension/mycompany/skeleton";

The uri that the extension will be listening on is configured with the WORKER_URI_PATH attribute in your extension. This is a required parameter and must be unique in the system.

We recommend that, to prevent naming collision, a naming structure of the form /extension/mycompany/component be used in production and development environments. This will allow you more flexibility by identifying your extension by company/department and not mistake them for F5 core services.

Make sure to ensure that this URI is unique - no other extensions may declare the same URI path. To understand which extension is listening on which URI, after deployment, look for log entries on startup of the form:

$ tail  /var/log/restnoded/restnoded.log
config: [RestWorker] /extension/mycompany/memory has started. Name:MemoryWorker

isPublic

MemoryWorker.prototype.isPublic = true;

For your extension to be available, you must set it’s visibility with the isPublic boolean attribute. A setting of true indicates the extension is exposed through the external management port. A value of false will only allow access from localhost connections on the device.

Startup Events

onStart

MemoryWorker.prototype.onStart = function(success, error) {
    //if the logic in your onStart implementation encounters and error
    //then call the error callback function, otherwise call the success callback
    var err = false;
    if (err) {
        this.logger.severe("MemoryWorker onStart error: something went wrong");
        error();
    } else {
        this.logger.info("MemoryWorker onStart success");
        success();
    }
};

You can handle your extension startup logic in the onStart event which is the first function that is called by the framework. It is called after the F5 library initialization and is especially useful for dependencies (discussed later in this article). The extension can indicate to the framework whether to continue with loading by calling either the success or error callback. You can also pass an error message string to the error callback. This is an optional event.

onStartCompleted

MemoryWorker.prototype.onStartCompleted = function (success, error, state, errMsg) {
    if (errMsg) {
        this.logger.severe("MemoryWorker onStartCompleted error: something went wrong " + errMsg);
        error();
        return;
     }

     this.logger.info("MemoryWorker state loaded: " + JSON.stringify(state));
    success();
};

The onStartCompleted event is the second function that is called by the framework during startup. The Framework calls this function when all dependencies that are set during the onStart step are available. When the extension state is persisted, it will be optionally loaded and passed in as a parameter to allow for initialization. Typically you need to implement this event when you need to use persisted state loaded from storage and need to perform additional initialization steps. Asynchronous operations can be performed from within the onStartCompleted event. You can pass back indication of whether the initialization was a success by calling the success or error callbacks. You can also pass an error message string to the error callback. The framework will mark the extension as available and dispatch REST requests only after this step is completed. This is an optional event.

Event Handlers

onGet

MemoryWorker.prototype.onGet = function(restOperation) {
    restOperation.setBody(this.state);
    this.completeRestOperation(restOperation);
};

The onGet event is called when a GET request is sent to the listening REST API URI. The parameter restOperation holds the incoming request with URI and any other meta data like query parameters. Implementing this function is optional. If not implemented, calls to this request will cause an error. The above example will retrieve the saved state and return that object to the caller.

onPost

MemoryWorker.prototype.onPost = function(restOperation) {
    this.state = restOperation.getBody();
    this.completeRestOperation(restOperation);
};

The onPost event is called when a POST request is sent to the listening REST API URI. The parameter restOperation holds the incoming request with POST body and meta data. Typical processing of a POST request involves validation of incoming body and updates to the current state. Implementing this function is optional. If not implemented, calls to this request will cause an error.

onPut

MemoryWorker.prototype.onPut = function(restOperation) {
    this.state = restOperation.getBody();
    this.completeRestOperation(restOperation);
};

The onPut event is called when a PUT request is sent to the listening REST API URI. Everything else is similar to onPost. Implementing this function is optional. If not implemented, calls to this request will cause an error.

onPatch

MemoryWorker.prototype.onPatch = function(restOperation) {
    this.state = restOperation.getBody();
    this.completeRestOperation(restOperation);
};

The onPatch event is called when a PATCH request is sent to the listening REST API URI. Everything else is similar to onPost. Implementing this function is optional. If not implemented, calls to this request will cause an error.

onDelete

MemoryWorker.prototype.onDelete = function(restOperation) {
    this.state = {};
    this.completeRestOperation(restOperation.setBody(this.state));
};

The onDelete event is called when a DELETE request is sent to the listening REST API URI. Everything else is similar to onGet. Implementing this function is optional. If not implemented, calls to this request will cause an error.

MemoryWorker customization

After familiarizing yourself with the main components that make up the worker pattern, we will customize them a bit.

Setting the URI

The first thing we will want to do is to set the uri that your extension will be available on. This is achieved by modifying the WORKER_URI_PATH value. Look in your MemoryWorker source for the above line and change the value of WORKER_URI_PATH to one of your choosing. For this example we will use the uri value of “extension/mycompany/memory”.

MemoryWorker.prototype.WORKER_URI_PATH = "extension/mycompany/memory";

Now, after deployment your extension will be available at https://mgmt_address/mgmt/extension/mycompany/memory.

Modifying onGet To Return a Version String

A REST API extension point is designed to respond to the 5 REST requests: GET, PUT, POST, PATCH, DELETE. GET requests are designed not to change the state of the machine and are generally simplest, so we’ll start there.

In this example, let’s add logic to return the current version number in response to a GET request. Find the onGet function in your MemoryWorker code and replace with the following:

MemoryWorker.prototype.onGet = function(restOperation) {
  // Set the version number variable
  var o = new Object();
  o.version = "1.1";
  // Send a message to the logging system
  this.logger.info("current version is " + o.version);
  // Set the response payload to the prepared object
  restOperation.setBody(o);
  // complete the REST operation and return control to the caller
  this.completeRestOperation(restOperation);
}

After deployment, a GET request will return the following:

curl ... https://bigip_address/mgmt/extension/mycompany/memory
 {
   "version": "1.1"
 }

Adding Logging

You will notice in the above change to onGet, we made use of the logger object. F5 provides access to the restnoded logging using the f5-logger module. To use, include the f5-logger module in your module.

The f5-logger module includes the following log levels:

  • logger.finest(“”)
  • logger.finer(“”)
  • logger.fine(“”)
  • logger.info(“”)
  • logger.warning(“”)
  • logger.severe(“”)

By default, only info, warning, and severe log levels are enabled.

The logs will be stored at /var/log/restnoded/restnoded.log and subject to log-rolling policies.

If you happen to use the console.log(“”) command to issue log statements, it’s output will be stored in /var/tmp/restnoded.out. This practice is discouraged and not guaranteed to behave consistently in future versions. Please use the f5-logger module for any logging needs.

Adding Persistence with Incoming Data and In-memory State

Since our sample is meant to remember data we give it on PUT and give it back on GET request, we should process a PUT request next. In this example, we will expect the data to be passed as the Data field inside the body of the message. The desired output of the API is:

$ curl -X PUT http://bigip_address/mgmt/extension/mycompany/memory -d '{ "Data":"Hello"}'
{"version":"1.1","Data":"Hello"}

$ curl -X GET http://bigip_address/mgmt/extension/mycompany/memory
{"version":"1.1","Data":"Hello"}

To find the Data input, we inspect the restOperation.body - refer to the restOperation reference for additional useful information you can find in the restOperation structure. We’ll store the data in this.state.

MemoryWorker.prototype.onPut = function(restOperation) {
  // Access the Data attribute in the POST body
 var newData = restOperation.getBody().Data;
  // Store the value of Data in the state's Data attribute
  this.state.Data = newData;
  // Complete the PUT operation
  this.completeRestOperation(restOperation);
}

To return the value, we’ll modify the onGet event to return the persisted data.

MemoryWorker.prototype.onGet = function(restOperation) {
  // Set the version number variable
 var o = new Object();
  o.version = "1.1";

  // Add the persisted "Data" attribute to the response object
  o.Data = this.state.Data;

  // Send a message to the logging system
  this.logger.info("current version is " + o.version);
  // Set the response payload to the prepared object
  restOperation.setBody(o);
  // complete the REST operation and return control to the caller
  this.completeRestOperation(restOperation);
}

We can similarly handle POST calls. At present, POST should be handled the same way as PUT.

MemoryWorker.prototype.onPost = function(restOperation) {
  // Access the Data attribute in the POST body
  var newData = restOperation.getBody().Data;
  // Store the value of Data in the state's Data attribute
  this.state.Data = newData;
  // Complete the PUT operation
  this.completeRestOperation(restOperation);
}

Returning errors for unsupported verbs

In this example, the PATCH verb has no meaning, so we should return a 405 status code for PATCH calls. This is accomplished by removing or commenting out the method for that verb. iControl LX will then automatically generate the status code and message for unsupported verbs.

/*
SkeletonWorker.prototype.onPatch = function(restOperation) {
    ...
};
*/

After deployment a PATCH request will result in an error as follows:

$ curl -X PATCH http://bigip_address/mgmt/extension/mycompany/memory -d '{ "Data":"Hello"}'
 {
   "code": 405,
   "message": "Patch not allowed for extension/mycompany/memory",
   "originalRequestBody": "{\"Data\":\"Hello\"}",
   "referer": "restnoded",
   "restOperationId": 0,
   "kind": ":resterrorresponse"
 }

Additional Basic Extension Concepts

apiStatus

MemoryWorker.prototype.apiStatus = "GA";

The apiStatus property is used to configure the extension’s API LifeCycle state. Possible values for apiStatus are:

  • NO_STATUS - Empty no-state state (Default)
  • EARLY_ACCESS - Early Access indicating it is meant for pre-production testing and it may be changed in the future.
  • GA - Extension is release-ready and fully supported.
  • DEPRECATED - GA extensions can move to DEPRECATED states which indicates the extension is flagged for removal in the future.
  • TEST_ONLY - The extension is not publicly available and only available via the internal loopback when in test mode.
  • INTERNAL_ONLY - The extension is only routable on the internal loopback address of the installed device.

getExampleState

MemoryWorker.prototype.getExampleState = function () {
    return {
        content: "sample data",
        integerContent: 1,
        stage: stageEnumValues[0]
    };
};

The getExampleState function is used to provide example state of the extension. This will be returned when a GET request is made on /example helper URI of the extension. This function is optional.

Mandatory REST Framework Properties

Every response by the extension must include the REST framework properties:

  • selfLink - Namespace URI of the resource. For resources it is based on the resource ID.
  • kind - Type of the resource. This property always comes first. Kind should be a globally unique kind within the namespace. Kind is comprised of the REST worker path prefix plus the name of the class name that is the resource separated with “colons” (e.g. cm:firewall:working-config:rule-lists:RuleListState). Collections return the kind of resources they represent.
  • generation - Monotonically increasing sequence number for a resource; initial value 0, incremented on any changes to the resource.
  • lastUpdateMicros - Last update to this resource in microseconds.

The extension calls the completeRestOperation function to send the response. The framework automatically adds these properties to the response when they are not present in the response body. To override these values, the following example can be used as a guide:

var kind = this.restHelper.makeKind(this.WORKER_URI_PATH, this);
var selfLink = this.restHelper.makePublicUri(this.getUri()).href;
var lastUpdateMicros = this.restUtil.getNowMicrosUtc();

If you design your extension to persist the extension’s state, the framework manages the kind, selfLink, lastUpdateMicros properties as well as incrementing generation during updates. If you design your extension not to persist the state, then you must:

  • initialize kind and selfLink
  • provide a means to increment the value of generation by +1
  • and update lastUpdateMicros for each change on every POST, PUT, or PATCH request.

This will enable a customer of the resource to detect when the data has changed. Refer to other concept guides for examples on how to implement persisted extensions.

Test Extension for Syntax Errors

Before you proceed to packaging your extension, make sure that the JavaScript syntax is valid. This can be done by running your code through the node command line. If you don’t see any errors, it should be syntactically valid.

$ node MemoryWorker.js

If you are getting syntax errors, you can compare your work to the supplied MemoryWorker.js <HowToSamples_MemoryWorker_js.html reference sample.

Packaging and Deploying the Extension

At this point, you should have a working extension that you are ready to package and deploy. Refer to Creating a Distribution iControl LX/iApps LX RPM Package and Working With iControl LX/iApps LX RPM Packages to guide you through this process.