Skip to content

Commit

Permalink
Introducing the Container provider
Browse files Browse the repository at this point in the history
This pr introduces the container provider.
The first supported bind is Docker, though a Podman bind is in progress.
A number of other supporting concepts have also been introduced, most
notably are "binds". These are small abstraction layers around an
external API that ease the integration into Broker.
  • Loading branch information
JacobCallahan committed Apr 5, 2022
1 parent fd5a885 commit eaf44e9
Show file tree
Hide file tree
Showing 26 changed files with 927 additions and 80 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ jobs:
pip install -U pip
pip install -U .[test]
cp broker_settings.yaml.example broker_settings.yaml
pytest -v tests/ --ignore tests/cli_scenarios
pytest -v tests/ --ignore tests/functional
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
venv*/
ven*/
build/
develop-eggs/
dist/
Expand Down
97 changes: 84 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The infrastrucure middleman


# Description
Broker is a tool designed to provide a common interface between one or many services that provision virtual machines. It is an abstraction layer that allows you to ignore (most) of the implementation details and just get what you need.
Broker is a tool designed to provide a common interface between one or many services that provision virtual machines or containers. It is an abstraction layer that allows you to ignore most of the implementation details and just get what you need.

# Installation
```
Expand All @@ -24,18 +24,18 @@ Broker can also be ran outside of its base directory. In order to do so, specify
# Configuration
The broker_settings.yaml file is used, through DynaConf, to set configuration values for broker's interaction with its 'providers'.

DynaConf integration provides support for setting environment variables to override any settings from the yaml file.
DynaConf integration provides support for setting environment variables to override any settings from broker's config file.

An environment variable override would take the form of: `DYNACONF_AnsibleTower__base_url="https://my.ansibletower.instance.com"`. Note the use of double underscores to model nested maps in yaml.
An environment variable override would take the form of: `BROKER_AnsibleTower__base_url="https://my.ansibletower.instance.com"`. Note the use of double underscores to model nested maps in yaml.

Broker allows for multiple instances of a provider to be in the configuration file. You can name an instance anything you want, then put instance-specfic settings nested under the instance name. One of your instances must have a setting `default: True`.
Broker allows for multiple instances of a provider to be in its config file. You can name an instance anything you want, then put instance-specfic settings nested under the instance name. One of your instances must have a setting `default: True`.

For the AnsibleTower provider, authentication can be achieved either through setting a username and password, or through a token (Personal Access Token in Tower).

A username can still be provided when using a token to authenticate. This user will be used for inventory sync (examples below). This may be helpful for AnsibleTower administrators who would like to use their own token to authenticate, but want to set a different user in configuration for checking inventory.
A username can still be provided when using a token to authenticate. This user will be used for inventory sync (examples below). This might be helpful for AnsibleTower administrators who would like to use their own token to authenticate, but want to set a different user in configuration for checking inventory.

# Usage
**Checking out a VM**
# CLI Usage
**Checking out a VM or container**
```
broker checkout --workflow "workflow-name" --workflow-arg1 something --workflow-arg2 else
```
Expand All @@ -56,6 +56,7 @@ You can also pass in a file for other arguments, where the contents will become
```
broker checkout --nick rhel7 --extra tests/data/args_file.yaml
```
**Note:** Check with the provider to determine specific arguments.

**Nicks**

Expand All @@ -74,9 +75,9 @@ broker duplicate 1 3
broker duplicate 0 --count 2
```

**Listing your VMs**
**Listing your VMs and containers**

Broker maintains a local inventory of the VMs you've checked out. You can see these with the ```inventory``` command.
Broker maintains a local inventory of the VMs and containers you've checked out. You can see these with the ```inventory``` command.
```
broker inventory
```
Expand All @@ -88,13 +89,13 @@ To sync an inventory for a specific user, use the following syntax with `--sync`
```
broker inventory --sync AnsibleTower:<username>
```
To sync an inventory for a specific instance, use the follow syntax with --sync.
To sync an inventory for a specific instance, use the following syntax with --sync.
```
broker inventory --sync AnsibleTower::<instance name>
broker inventory --sync Container::<instance name>
```
This can also be combined with the user syntax above.
```
broker inventory --sync AnsibleTower:<username>::<instance name>
broker inventory --sync Container:<username>::<instance name>
```


Expand All @@ -108,9 +109,10 @@ broker extend vmname
broker extend --all
```

**Checking in VMs**
**Checking in VMs and containers**

You can also return a VM to its provider with the ```checkin``` command.
Containers checked in this way will be fully deleted regardless of its status.
You may use either the local id (```broker inventory```), the hostname, or "all" to checkin everything.
```
broker checkin my.host.fqdn.com
Expand All @@ -132,6 +134,7 @@ broker providers AnsibleTower --workflow remove-vm
**Run arbitrary actions**

If a provider action doesn't result in a host creation/removal, Broker allows you to execute that action as well. There are a few output options available as well.
When executing with the Container provider, a new container will be spun up with your command (if specified), ran, and cleaned up.
```
broker execute --help
broker execute --workflow my-awesome-workflow --additional-arg True
Expand Down Expand Up @@ -182,3 +185,71 @@ You can also chain multiple filters together by separating them with a comma. Th
`--filter 'name<test,_broker_args.provider!=RHEV'` The host's name should have test in it and the provider should not equal RHEV.

**Note:** Due to shell expansion, it is recommended to wrap a filter in single quotes.

# API Usage
**Basics:**

Broker also exposes most of the same functionality the CLI provides through a Broker class.
To use this class, simply import:
```python
from broker import Broker
```
The Broker class largely accepts the same arguments as you would pass via the CLI. One key difference is that you need to use underscores instead of dashes. For example, a checkout at the CLI that looks like this
```
broker checkout --nick rhel7 --args-file tests/data/broker_args.json
```
could look like this in an API usage
```python
rhel7_host = Broker(nick="rhel7", args_file="tests/data/broker_args.json").checkout()
```
Broker will carry out its usual actions and package the resulting host in a Host object. This host object will also include some basic functionality, like the ability to execute ssh commands on the host.
Executed ssh command results are packaged in a Results object containing status (return code), stdout, and stderr.
```python
result = rhel7_host.execute("rpm -qa")
assert result.status == 0
assert "my-package" in result.stdout
```


**Recommended**

The Broker class has a built-in context manager that automatically performs a checkout upon enter and checkin upon exit. It is the recommended way of interacting with Broker for host management.
In the below two lines of code, a container host is created (pulled if needed or applicable), a broker Host object is constructed, the host object runs a command on the container, output is checked, then the container is checked in.
```python
with Broker(container_host="ch-d:rhel7") as container_host:
assert container_host.hostname in container_host.execute("hostname").stdout
```


**Custom Host Classes**

You are encouraged to build upon the existing Host class broker provides, but need to include it as a base class for Broker to work with it properly. This will allow you to build upon the base functionality Broker already provides while incorporating logic specific to your use cases.
Once you have a new class, you can let broker know to use it during host construction.
```python
from broker import Broker
from broker.hosts import Host

class MyHost(Host):
...

with Broker(..., host_classes={'host': MyHost}) as my_host:
...
```


**Setup and Teardown**

Sometimes you might want to define some behavior to occur after a host is checked out but before Broker gives it to you. Alternatively, you may want to define teardown logic that happens right before a host is checked in.
When using the Broker context manager, Broker will run any `setup` or `teardown` method defined on the Host object.
Broker *will not* pass any arguments to the `setup` or `teardown` methods, so they must not accept arguments.
```python
<continued from above>
class MyHost(Host):
...
def setup(self):
self.register()

def teardown(self):
self.unregister()
```
**Note:** One important thing to keep in mind is that Broker will strip any non-pickleable attributes from Host objects when needed. If you encounter this, then it is best to construct your host classes in such a way that they can recover gracefully in these situations.
4 changes: 3 additions & 1 deletion broker/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from broker.broker import VMBroker
from broker.broker import Broker

VMBroker = Broker
Empty file added broker/binds/__init__.py
Empty file.
97 changes: 97 additions & 0 deletions broker/binds/containers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
class ContainerBind:
def __init__(self, host=None, username=None, password=None, port=22):
self.host = host
self.username = username
self.password = password
self.port = port
self._client = None
self._ClientClass = None

@property
def client(self):
if not isinstance(self._client, self._ClientClass):
self._client = self._ClientClass(base_url=self.uri)
return self._client

@property
def images(self):
return self.client.images.list()

@property
def containers(self):
return self.client.containers.list(all=True)

def image_info(self, name):
if image := self.client.images.get(name):
return {
"id": image.short_id,
"tags": image.tags,
"size": image.attrs["Size"],
"config": {
k: v for k, v in image.attrs["Config"].items() if k != "Env"
},
}

def create_container(self, image, command=None, **kwargs):
"""Create and return running container instance"""
return self.client.containers.create(image, command, **kwargs)

def execute(self, image, command=None, remove=True, **kwargs):
"""Run a container and return the raw result"""
return self.client.containers.run(
image, command=command, remove=remove, **kwargs
).decode()

def remove_container(self, container=None):
if container:
container.remove(v=True, force=True)

def pull_image(self, name):
return self.client.images.pull(name)

@staticmethod
def get_logs(container):
return "\n".join(map(lambda x: x.decode(), container.logs(stream=False)))

@staticmethod
def get_attrs(cont):
return {
"id": cont.id,
"image": cont.attrs.get("ImageName", cont.attrs["Image"]),
"name": cont.name or cont.attrs["Names"][0],
"container_config": cont.attrs.get("Config", {}),
"host_config": cont.attrs.get("HostConfig", {}),
"ports": cont.ports or cont.attrs.get("Ports"),
}

def __repr__(self):
inner = ", ".join(
f"{k}={v}"
for k, v in self.__dict__.items()
if not k.startswith("_") and not callable(v)
)
return f"{self.__class__.__name__}({inner})"


class PodmanBind(ContainerBind):
def __init__(self, host=None, username=None, password=None, port=22):
super().__init__(host, username, password, port)
from podman import PodmanClient

self._ClientClass = PodmanClient
if self.host == "localhost":
self.uri = "unix:///run/user/1000/podman/podman.sock"
else:
self.uri = f"http+ssh://{username}@{host}:{port}/run/podman/podman.sock"


class DockerBind(ContainerBind):
def __init__(self, host=None, username=None, password=None, port=22):
super().__init__(host, username, password, port)
from docker import DockerClient

self._ClientClass = DockerClient
if self.host == "localhost":
self.uri = "unix://var/run/docker.sock"
else:
self.uri = f"tcp://{host}:{port}"
34 changes: 25 additions & 9 deletions broker/broker.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
from logzero import logger
from broker.providers.ansible_tower import AnsibleTower
from broker.providers.container import Container
from broker.providers.test_provider import TestProvider
from broker.hosts import Host
from broker import exceptions, helpers
from concurrent.futures import ProcessPoolExecutor, as_completed


PROVIDERS = {"AnsibleTower": AnsibleTower, "TestProvider": TestProvider}
PROVIDERS = {
"AnsibleTower": AnsibleTower,
"Container": Container,
"TestProvider": TestProvider,
}

PROVIDER_ACTIONS = {
# action: (InterfaceClass, "method_name")
"workflow": (AnsibleTower, "execute"),
"job_template": (AnsibleTower, "execute"),
"template": (AnsibleTower, None), # needed for list-templates
"test_action": (TestProvider, "test_action"),
"inventory": (AnsibleTower, None),
"container_host": (Container, "run_container"),
"container_app": (Container, "execute"),
"test_action": (TestProvider, "test_action"),
}


class mp_decorator:
"""This decorator wraps VMBroker methods to enable multiprocessing
"""This decorator wraps Broker methods to enable multiprocessing
The decorated method is expected to return an itearable.
"""
Expand Down Expand Up @@ -70,7 +77,7 @@ def mp_split(*args, **kwargs):
return mp_split


class VMBroker:
class Broker:
# map exceptions for easier access when used as a library
BrokerError = exceptions.BrokerError
AuthenticationError = exceptions.AuthenticationError
Expand All @@ -81,10 +88,10 @@ class VMBroker:

def __init__(self, **kwargs):
kwargs = helpers.resolve_file_args(kwargs)
logger.debug(f"Broker instantiated with {kwargs=}")
self._hosts = kwargs.pop("hosts", [])
self.host_classes = {"host": Host}
# if a nick was specified, pull in the resolved arguments
logger.debug(f"Broker instantiated with {kwargs=}")
if "nick" in kwargs:
nick = kwargs.pop("nick")
kwargs = helpers.merge_dicts(kwargs, helpers.resolve_nick(nick))
Expand Down Expand Up @@ -131,7 +138,6 @@ def _checkout(self):
logger.info(f"Using provider {provider.__name__} to checkout")
try:
host = self._act(provider, method, checkout=True)
logger.debug(f"host={host}")
except exceptions.ProviderError as err:
host = err
if host and not isinstance(host, exceptions.ProviderError):
Expand Down Expand Up @@ -188,8 +194,10 @@ def _checkin(self, host):
host.close()
try:
host.release()
except Exception:
pass
except Exception as err:
logger.warning(f"Encountered exception during checkin: {err}")
raise
# pass
return host

def checkin(self, sequential=False, host=None):
Expand Down Expand Up @@ -299,7 +307,7 @@ def sync_inventory(provider):
instance = {provider: instance}
prov_inventory = PROVIDERS[provider](**instance).get_inventory(additional_arg)
curr_inventory = [
host["hostname"] or host["name"]
host.get("hostname", host.get("name"))
for host in helpers.load_inventory()
if host["_broker_provider"] == provider
]
Expand Down Expand Up @@ -328,6 +336,14 @@ def from_inventory(self, filter=None):
inv_hosts = helpers.load_inventory(filter=filter)
return [self.reconstruct_host(inv_host) for inv_host in inv_hosts]

def __repr__(self):
inner = ", ".join(
f"{k}={v}"
for k, v in self.__dict__.items()
if not k.startswith("_") and not callable(v)
)
return f"{self.__class__.__name__}({inner})"

def __enter__(self):
try:
hosts = self.checkout()
Expand Down
Loading

0 comments on commit eaf44e9

Please sign in to comment.