Custom Plugin Support

Need to expand Runway to wrap other tools? Yes - you can do that with custom plugin support.

Overview

Runway can import Python modules that can perform custom deployments with your own set of Runway modules. Let’s say for example you want to have Runway execute an Ansible playbook to create an EC2 security group as one of the steps in the middle of your Runway deployment list - this is possible with your own plugin. The custom plugin support allows you to mix-and-match natively supported modules (e.g. CloudFormation, Terraform) with plugins you write providing additional support for non-native modules. Although written in Python, these plugins can natively execute non-Python binaries.

RunwayModule Class

Runway provides RunwayModule to use as the base class of all module handler classes. This base class will give you the ability to write your own module handler class that can be added to your runway.yml deployment list (More info on runway.yml below). There are four methods that need to be defined for the class:

deploy

This method is called when runway deploy is run.

destroy

This method is called when runway destroy is run.

init

This method is called when runway init is run.

plan

This method is called when runway plan is run.

Context Object

self.ctx includes many helpful resources for use in your Python module.

Some notable examples are:

  • self.ctx.env.name - name of the environment

  • self.ctx.env.aws_region - region in which the module is being executed

  • self.ctx.env.vars - OS environment variables provided to the module

  • self.path - path to your Runway module folder

runway.yml Example

After you have written your plugin, you need to add the module class_path to your module’s configuration. Below is an example runway.yml containing a single module that looks for an Ansible playbook in a folder at the root of your Runway environment (i.e. repo) named “security_group.ansible”.

Setting class_path tells Runway to import the DeployToAWS Python class, from a file named Ansible.py in a folder named “local_runway_extensions” (Standard Python import conventions apply). Runway will execute the deploy function in your class when you perform a runway deploy (AKA takeoff).

deployments:
  - modules:
      - path: security_group.ansible
        class_path: local_runway_extensions.Ansible.DeployToAWS
    regions:
      - us-east-1

Below is the Ansible.py module referenced above that wraps the ansible-playbook command. It will be responsible for deploying an EC2 Security Group from the playbook with a naming convention of <env>-<region>.yaml within a fictional security_group.ansible Runway module folder. In this example, the ansible-playbook binary would already have been installed prior to a Runway deploy, but this example does check to see if it is installed before execution and logs an error if not. The Runway plugin will only execute the ansible-playbook against a yaml file associated with the environment and set for the Runway execution and region defined in the runway.yml.

Using the above runway.yml and the plugin/playbook below saved to the Runway module folder you will only have a deployment occur in the dev environment in us-east-1. If you decide to perform a runway deployment in the prod environment, or in a different region, the ansible-playbook deployment will be skipped. This matches the behavior of the Runway’s native modules.

"""Ansible Plugin example for Runway."""
from __future__ import annotations

import logging
import subprocess
import sys
from typing import TYPE_CHECKING, Dict

from runway.module.base import RunwayModule
from runway.utils import which

if TYPE_CHECKING:
    from pathlib import Path

LOGGER = logging.getLogger("runway")


def check_for_playbook(playbook_path: Path) -> Dict[str, bool]:
    """Determine if environment/region playbook exists."""
    if playbook_path.is_file():
        LOGGER.info("Processing playbook: %s", playbook_path)
        return {"skipped_configs": False}
    LOGGER.error(
        "No playbook for this environment/region found -- looking for %s",
        playbook_path,
    )
    return {"skipped_configs": True}


class DeployToAWS(RunwayModule):
    """Ansible Runway Module."""

    def deploy(self) -> None:
        """Run ansible-playbook."""
        if not which("ansible-playbook"):
            LOGGER.error(
                '"ansible-playbook" not found in path or is not '
                "executable; please ensure it is installed"
                "correctly."
            )
            sys.exit(1)
        playbook_path = self.path / f"{self.ctx.env.name}-{self.ctx.env.aws_region}"
        response = check_for_playbook(playbook_path)
        if response["skipped_configs"]:
            return
        subprocess.check_output(["ansible-playbook", str(playbook_path)])

    def destroy(self) -> None:
        """Skip destroy."""
        LOGGER.info("destroy not currently supported for Ansible")

    def init(self) -> None:
        """Skip init."""
        LOGGER.info("init not currently supported for Ansible")

    def plan(self) -> None:
        """Skip plan."""
        LOGGER.info("plan not currently supported for Ansible")

And below is the example Ansible playbook itself, saved as dev-us-east-1.yaml in the security_group.ansible folder:

- hosts: localhost
  connection: local
  gather_facts: false
  tasks:
      - name: create a security group in us-east-1
        ec2_group:
          name: dmz
          description: Dev example ec2 group
          region: us-east-1
          rules:
            - proto: tcp
              from_port: 80
              to_port: 80
              cidr_ip: 0.0.0.0/0
        register: security_group

The above would be deployed if runway deploy was executed in the dev environment to us-east-1.