Part 4: Refactoring code to use the Application Framework

Up until this point we wrote the Plone application in a manner that was common to all applications that were written before the application framework was introduced.

In this last tutorial step we are going to refactor the Plone code in order to take advantage of the framework.

Application framework was written in order to simplify the application development and encapsulate common deployment workflows. This gives things primitives for application scaling and high availability without the need to develop them over and over again for each application.

When using the frameworks, an application developer only has to inherit the class that best suits him and provide it only with the code that is specific to the application, while leaving the rest to the framework. This typically includes:

  • instructions on how to provision the software on each node (server)
  • instructions on how to configure the provisioned software
  • server group onto which the software should be installed. This may be a fixed server list, a shared server pool, or a scalable server group that creates servers using the given instance template, or one of the several other implementations provided by the framework

The framework is located in a separate library package io.murano.applications that is shipped with Murano. We are going to use the apps namespace prefix to refer to this namespace through the code.

Step 1: Add dependency on the App Framework

In order to use one Murano Package from another, the former must be explicitly specified as a requirement for the latter. This is done by filling the Require section in the package’s manifest file.

Open the Plone’s manifest.yaml file and append the following lines:

Require:
  io.murano.applications:

Requirements are specified as a mapping from package name to the desired version of that package (or version range). The missing value indicates the dependency on the latest 0.*.* version of the package which is exactly what we need since the current version of the app framework library is 0.

Step 2: Get rid of the instance

Since we are going to have a multi-sever Plone application there won’t be a single instance belonging to the application. Instead, we are going to provide it with the server group that abstracts the server management from the application.

So instead of

Properties:
  instance:
    Contract: $.class(res:Instance)

we are going to have

Properties:
  servers:
    Contract: $.class(apps:ServerGroup).notNull()

Step 3: Change the base classes

Another change that we are going to make to the main application class is to change its base classes. Regular applications inherit from the std:Application which only has the method deploy that does all the work.

Application framework provides us with its own implementation of that class and method. Instead of one monolithic method that does everything, with the framework, the application provides only the code needed to provision and configure the software on each server.

So instead of std:Application class we are going to inherit two of the framework classes:

Extends:
  - apps:MultiServerApplicationWithScaling
  - apps:OpenStackSecurityConfigurable

The first class tells us that we are going to have an application that runs on multiple servers. In the following section we are going to split out deploy method into two smaller methods that are going to be invoked by the framework to install the software on each of the servers. By inheriting the apps:MultiServerApplicationWithScaling, the application automatically gets all the UI buttons to scale it out and in.

The second class is a mix-in class that tells the framework that we are going to provide the OpenStack-specific security group configuration for the application.

Step 4: Split the deployment logic

In this step we are going to split the installation into two phases: provisioning and configuration.

Provisioning is implemented by overriding the onInstallServer method, which is called every time a new server is added to the server group. In this method we are going to install the Plone software bits onto the server (which is provided as a method parameter).

Configuration is done through the onConfigureServer, which is called upon the first installation on the server, and every time any of the application settings change, and onCompleteConfiguration which is executed on each server after everything was configured so that we can perform post-configuration steps like starting application daemons and reporting messages to the user.

Thus we are going to split the install-plone.sh script into two scripts: installPlone.sh and configureServer.sh and execute each one in their corresponding methods:

onInstallServer:
  Arguments:
    - server:
        Contract: $.class(res:Instance).notNull()
    - serverGroup:
        Contract: $.class(apps:ServerGroup).notNull()
  Body:
    - $file: sys:Resources.string('installPlone.sh').replace({
          "$1" => $this.deploymentPath,
          "$2" => $this.adminPassword
        })
    - conf:Linux.runCommand($server.agent, $file)

onConfigureServer:
  Arguments:
    - server:
        Contract: $.class(res:Instance).notNull()
    - serverGroup:
        Contract: $.class(apps:ServerGroup).notNull()
  Body:
    - $primaryServer: $serverGroup.getServers().first()
    - If: $server = $primaryServer
      Then:
        - $file: sys:Resources.string('configureServer.sh').replace({
              "$1" => $this.deploymentPath,
              "$2" => $primaryServer.ipAddresses[0]
            })
      Else:
        - $file: sys:Resources.string('configureClient.sh').replace({
            "$1" => $this.deploymentPath,
            "$2" => $this.servers.primaryServer.ipAddresses[0],
            "$3" => $this.listeningPort})
    - conf:Linux.runCommand($server.agent, $file)


  onCompleteConfiguration:
    Arguments:
      - servers:
          Contract:
            - $.class(res:Instance).notNull()
      - serverGroup:
          Contract: $.class(apps:ServerGroup).notNull()
      - failedServers:
          Contract:
            - $.class(res:Instance).notNull()
    Body:
      - $startCommand: format('{0}/zeocluster/bin/plonectl start', $this.deploymentPath)
      - $primaryServer: $serverGroup.getServers().first()
      - If: $primaryServer in $servers
        Then:
          - $this.report('Starting DB node')
          - conf:Linux.runCommand($primaryServer.agent, $startCommand)
          - conf:Linux.runCommand($primaryServer.agent, 'sleep 10')

      - $otherServers: $servers.where($ != $primaryServer)
      - If: $otherServers.any()
        Then:
          - $this.report('Starting Client nodes')
          # run command on all other nodes in parallel with pselect
          - $otherServers.pselect(conf:Linux.runCommand($.agent, $startCommand))

      # build an address string with IPs of all our servers
      - $addresses: $serverGroup.getServers().
          select(
            switch($.assignFloatingIp => $.floatingIpAddress,
                   true => $.ipAddresses[0])
            + ':' + str($this.listeningPort)
          ).join(', ')
      - $this.report('Plone listeners are running at ' + str($addresses))

During configuration phase we distinguish the first server in the server group from the rest of the servers. The first server is going to be the primary node and treated differently from the others.

Step 5: Configuring OpenStack security group

The last change to the main class is to set up the security group rules. We are going to do this by overriding the getSecurityRules method that we inherited from the apps:OpenStackSecurityConfigurable class:

getSecurityRules:
  Body:
    - Return:
        - FromPort: $this.listeningPort
          ToPort: $this.listeningPort
          IpProtocol: tcp
          External: true
        - FromPort: 8100
          ToPort: 8100
          IpProtocol: tcp
          External: false

The code is very similar to that of the old deploy method with the only difference being that it returns the rules rather than sets them on its own.

Step 6: Provide the server group instance

Do you remember, that previously we replaced the instance property with servers of type apps:ServerGroup? Since the object is coming from the UI definition, we must change the latter in order to provide the class with the apps:ServerReplicationGroup instance rather than resources:Instance.

To do this we are going to replace the instance property in the Application template with the following snippet:

servers:
  ?:
    type: io.murano.applications.ServerReplicationGroup
  numItems: $.ploneConfiguration.numNodes
  provider:
    ?:
      type: io.murano.applications.TemplateServerProvider
    template:
      ?:
        type: io.murano.resources.LinuxMuranoInstance
      flavor: $.instanceConfiguration.flavor
      image: $.instanceConfiguration.osImage
      assignFloatingIp: $.instanceConfiguration.assignFloatingIP
    serverNamePattern: $.instanceConfiguration.unitNamingPattern

If you take a closer look at the code above you will find out that the new declaration is very similar to the old one. But now instead of providing the Instance property values directly, we are providing them as a template for the TemplateServerProvider server provider. ServerReplicationGroup is going to use the provider each time it requires another server. In turn, the provider is going to use the familiar template for the new instances.

Besides the instance template we also specify the initial number of Plone nodes using the numItems property and the name pattern for the servers. Thus we must also add it to the list of our controls:

Forms:
  - instanceConfiguration:
      fields:
        ...
        - name: unitNamingPattern
          type: string
          label: Instance Naming Pattern
          required: false
          maxLength: 64
          initial: 'plone-{0}'
          description: >-
            Specify a string, that will be used in instance hostname.
            Just A-Z, a-z, 0-9, dash and underline are allowed.

  - ploneConfiguration:
      fields:
        ...
        - name: numNodes
          type: integer
          label: Initial number of Client Nodes
          initial: 1
          minValue: 1
          required: true
          description: >-
            Select the initial number of Plone Client Nodes

Step 6: Using server group composition

By this step we should already have a working Plone application. But let’s go one step further and enhance our sample application.

Since we are running the database on the first server group server only, we might want it to have different properties. For example we might want to give it a bigger flavor or just a special name. This is a perfect opportunity for us to demonstrate how to construct complex server groups. All we need to do is to just use another implementation of apps:ServerGroup. Instead of apps:ServerReplicationGroup we are going to use the apps:CompositeServerGroup class, which allows us to compose several server groups together. One of them is going to be a single-server server group consisting of our primary server, and the second is going to be the scalable server group that we used to create in the previous step.

So again, we change the Application section of our UI definition file with even a more advanced servers property definition:

servers:
  ?:
    type: io.murano.applications.CompositeServerGroup
  serverGroups:
    - ?:
        type: io.murano.applications.SingleServerGroup
      server:
        ?:
          type: io.murano.resources.LinuxMuranoInstance
        name: format($.instanceConfiguration.unitNamingPattern, 'db')
        image: $.instanceConfiguration.image
        flavor: $.instanceConfiguration.flavor
        assignFloatingIp: $.instanceConfiguration.assignFloatingIp
    - ?:
        type: io.murano.applications.ServerReplicationGroup
      numItems: $.ploneConfiguration.numNodes
      provider:
        ?:
          type: io.murano.applications.TemplateServerProvider
        template:
          ?:
            type: io.murano.resources.LinuxMuranoInstance
          flavor: $.instanceConfiguration.flavor
          image: $.instanceConfiguration.osImage
          assignFloatingIp: $.instanceConfiguration.assignFloatingIP
        serverNamePattern: $.instanceConfiguration.unitNamingPattern

Here the instance definition for the SingleServerGroup (our primary server) differs from the servers in the ServerReplicationGroup by its name only. However the same technique might be used to customize other properties as well as to create even more sophisticated server group topologies. For example, we could implement region bursting by composing several scalable server groups that allocate servers in different regions. And all of that without making any changes to the application code itself!