Extending Paco with Services

Paco has an add-on feature called Services.

A Paco Service is a Python module that is loaded during Paco initialization and is capable of extending or changing Paco in any way.

Services commonly provision cloud resources. For example, if you wanted to send CloudWatch Alarm notifications to a Slack Channel, you would need to send your Alarm messages to a custom Lambda. A Slack Service could provision this custom Lambda and customize your AlarmActions to send to messages this Lambda.

Services that provision resources have the PACO_SCOPE service.<servicename>:

$ paco validate service.slack
$ paco provision service.slack
$ paco delete service.slack

Creating a minimal Paco Service

The minimal requirments to create a Paco Service is to create a Python project and a service/<my-name>.yaml file in a Paco Project that will use that service.

Let’s take a look at what’s involved in a Paco Service that prints “Hello World” during Paco initialization.

First, create a mypacoaddon directory for your Paco Service and make it a Python project by creating a setup.py file. This file describes the layout of your Python project.

from setuptools import setup

setup(
    name='helloworld-service',
    version='0.1.dev',
    install_requires=['paco-cloud'],
    entry_points = {
        'paco.services': [
            'helloworld = mypacoaddon.helloworld',
        ],
    },
    packages=['mypacoaddon',],
    package_dir={'': 'src'},
)

The setup.py is described in standard Python packaging. The important parts to note are that the Paco Service should declare it depends on the paco-cloud Python project in the install_requires field.

The entry_points field will register paco.services entry points. You can register more than one Paco Service here. Each Paco Service declared is in the format <service-name> = <python-dotted-name-of-module>.

The Paco Service service name must be unique within the Services your Paco has installed.

A Python module that is a Paco Service must provide two functions:

def instantiate_model(config, project, monitor_config, read_file_path):
    "Return a Python object with configuration for this Service"
    pass

def instantiate_class(paco_ctx, config):
    "Return a Controller for this Service"
    pass

The load_service_model function is called during model loading. It could return any empty Python object, it could use paco.mdoel laoding APIs to read and validate custom YAML configuration or do any other kind of custom configuration initialization and loading you need.

The get_service_controller function is called during Controller initialization and it needs to return a Paco Controller specific to your Paco Service.

In your mypacoaddon project, create the following directory structure:

mypacoaddon/
  setup.py
  src/
    mypacoaddon/
      __init__.py
      helloworld.py

Then put this code into helloworld.py:

"""
Hello World Paco Service
"""

# Hook into the Paco Service loading

def load_service_model(config, project, monitor_config, read_file_path):
    return HelloWorldModel()

def get_service_controller(paco_ctx, config):
    "Return a HelloWorld controller for the HelloWorld Service"
    return HelloWorldController(config)

# Model and Controller

class HelloWorldModel:
    speak = "Hello World!"

class HelloWorldController:

    def __init__(self, config):
        self.config = config

    def init(self, command=None, model_obj=None):
        print(self.config.speak)

Next you can install your Python project from the mypacoaddon directory with the command pip install -e .. This will register your Paco Service entry point in your for your Python environment.

By default, if you run Paco commands on a Paco Project, if there is no file for your Paco Service in the services/ directory, then Paco will not load that Paco Service. This is by design to allow you to install a Paco Service but only use it in Paco Projects that you explicitly declare.

In a Paco Project, create a file services/helloworld.yaml. This can be an empty file or valid YAML that will be read into a Python data structure and passed as the argument config to your load_service_model function.

Now run any Paco command and you should see “Hello World!” printed on your terminal.

$ paco validate netenv.mynet.staging
Loading Paco project: /Users/example/my-paco-project
Hello World!
...

Service module specification

Every Paco Service Python module must have load_service_model and get_service_controller functions. These will be called when the Service is initialized. In addition, the module may optionally provide a SERVICE_INITIALIZATION_ORDER attribute.

"""
Example barebones Paco Service module
"""

# Every Paco Service *must*  provide these two functions
def load_service_model(config, project, monitor_config, read_file_path):
    pass

def get_service_controller(paco_ctx, config):
    pass

# Optional attribute
SERVICE_INITIALIZATION_ORDER = 1000

load_service_model

This required function loads the configuration YAML into model objects for your Service. However, it isn’t required for a Service to have any model and this method can simply return None.

If a Paco Project doesn’t have a service/<servicename>.yaml file, then that service is not considered active in that Paco Project and will NOT be enabled. The configuration file for a Service must be valid YAML or an empty file.

The load_service_model must accept four arguments:

  • config: A Python dict of the Services service/<servicename>.yaml file.
  • project: The root Paco Project model object.
  • monitor_config: A Python dict of the YAML loaded from config in the``monitor/`` directory.
  • read_file_path: The location of the file path of the Service’s YAML file.
class Notification:
    pass

def load_service_model(config, project, monitor_config, read_file_path):
    "Loads services/notification.yaml and returns a Notification model object"
    return Notification()

get_service_controller

This required function must return a Controller object for your Service.

The get_service_controller must accept two arguments:

  • paco_ctx: The PacoContext object contains the CLI arguments used to call Paco as well as other global information.
  • service_model: The model object returned from this Service’s load_service_model function.

A Controller must provide an init(self, command=None, model_obj=None) method. If the Service can be provisioned, it must also implement validate(self), provision(self) and delete(self) methods.

class NotificationServiceController:
    def init(self, command=None, model_obj=None):
        pass

def get_service_controller(paco_ctx, service_model):
    "Return a Paco controller"
    return NotificationServiceController()

SERVICE_INITIALIZATION_ORDER

The SERVICE_INITIALIZATION_ORDER attribute determines the initialization order of Services. This is useful for Services that need to do special initialization before other Services are initialized.

If this order is not declared the initialization order will be randomly assigned an order starting from 1000.

Overview of Paco Initialization

Every time Paco loads a Paco Project, it starts by determing which Services are installed and actived. Configuration for Services is in the service/ directory of a Paco Project. If a file exists at service/<service-name>.yaml than that Service will be active in that Paco Project. If a Service is installed with Paco but there is no service file, it is ignored.

All of the active Services are imported and given the chance to apply configuration that extends Paco.

Next, Paco reads all of the YAML files in the Paco Project and creates a Python object model from that YAML configuration.

Then Paco will initialize the Controllers that it needs. Controllers are high level APIs that implement Paco commands. Controllers can govern the creating, updating and deletion of cloud resources, typically by acting on the contents of the Paco model.

Controller initialization happens in a specific order:

  1. Controllers for Global Resources declared in the resource/ directory are initialized first. This allows other Controllers to depend upon global resources being already initialized and available.
  2. Service Controllers declared in the service/ are initialized second. They are initialized in an initialization_order that each Service add-on may declare. Controllers with a low initialization_order have a chance to make changes that effect the initialization of later Controllers.
  3. The Controller specific to the current PACO_SCOPE is initialized last. For example, if the command paco provision netenv.mynet.staging was run, the scope is a NetworkEnvironment and a NetworkEnvironment Controller will be initialized.

Paco Extend API

The paco.extend module contains convenience APIs to make it easier to extend Paco. These APIs will be typically called from your custom Paco Service Controllers.

paco.extend.add_cw_alarm_hook(hook)

Customize CloudWatchAlarm with a hook that is called before the Alarms are initialized into CloudFormation templates.

This is useful to add extra metadata to the CloudWatch Alarm’s AlarmDescription field. This can be done in the hook by calling the add_to_alarm_description method of the cw_alarm object with a dict of extra metadata.

import paco.extend

def my_service_alarm_description_function(cw_alarm):
    slack_metadata = {'SlackChannel': 'http://my-slack-webhook.url'}
    cw_alarm.add_to_alarm_description(slack_metadata)

paco.extend.add_cw_alarm_hook(my_service_alarm_description_function)
paco.extend.add_extend_model_hook(extend_hook)

Add a hook can extend the core Paco schemas and models. This hook is called first during model loading before any loading happens.

from paco.models import schemas
from paco.models.metrics import AlarmNotification
from zope.interface import Interface, classImplements
from zope.schema.fieldproperty import FieldProperty
from zope import schema

class ISlackChannelNotification(Interface):
    slack_channels = schema.List(
        title="Slack Channels",
        value_type=schema.TextLine(
            title="Slack Channel",
            required=False,
        ),
        required=False,
    )

def add_slack_model_hook():
    "Add an ISlackChannelNotification schema to AlarmNotification"
    classImplements(AlarmNotification, ISlackChannelNotification)
    AlarmNotification.slack_channels = FieldProperty(ISlackChannelNotification["slack_channels"])

paco.extend.add_extend_model_hook(add_slack_model_hook)
paco.extend.load_app_in_account_region(parent, account, region, app_name, app_config, project=None, monitor_config=None, read_file_path='not set')

Load an Application from config into an AccountContainer and RegionContainer. Account can be a paco.ref but then the Paco Project must be supplied too.

paco.extend.load_package_yaml(package, filename, replacements={})

Read a YAML file from the same directory as a Python package and parse the YAML into Python data structures.

paco.extend.override_cw_alarm_actions(hook)

Add a hook to change CloudWatch Alarm AlarmAction’s to your own custom list of SNS Topics. This can be used to send AlarmActions to notify your own custom Lambda function instead of sending Alarm messages directly to the SNS Topics that Alarms are subscribed too.

The hook is a function that accepts an alarm arguments and must return a List of paco.refs to SNS Topic ARNs.

def override_alarm_actions_hook(snstopics, alarm):
    "Override normal alarm actions with the SNS Topic ARN for the custom Notification Lambda"
    return ["paco.ref service.notify...snstopic.arn"]

paco.extend.override_cw_alarm_actions(override_alarm_actions_hook)
paco.extend.register_model_loader(obj, fields_dict, force=False)

Register a new object to the model loader registry.

The obj is the object to register loadable fields for.

The fields_dict is a dictionary in the form:

{ '<fieldname>': ('loader_type', loader_args) }

If an object is already registered, an error will be raised unless force=True is passed. Then the new registry will override any existing one.

For example, to register a new Notification object with slack_channels and admins fields:

paco.extend.register_model_loader(
    Notification, {
        'slack_channels': ('container', (SlackChannels, SlackChannel))
        'admins': ('obj_list', Administrator)
    }
)