Merge pull request #1 from stackhpc/proof-of-concept

[WIP] Proof of concept
This commit is contained in:
Mark Goddard 2018-08-28 16:48:30 +01:00 committed by GitHub
commit bbb727dda8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 702 additions and 0 deletions

9
.gitignore vendored
View File

@ -1,3 +1,12 @@
# All dot-files
.*
# Ansible retry files
*.retry
# Tenks allocations file
allocations.yml
# Tenks Galaxy roles
ansible/roles/stackhpc.*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

18
README.md Normal file
View File

@ -0,0 +1,18 @@
Tenks
=====
Tenks is a utility that manages virtual bare metal clusters for development and
testing purposes.
Getting Started
---------------
Tenks has dependencies on Ansible roles that are hosted by Ansible Galaxy.
Given that your virtualenv of choice is active and Ansible (>=2.6) is
installed inside it, Tenks' role dependencies can be installed by
`ansible-galaxy install --role-file=requirements.yml
--roles-path=ansible/roles/`.
Currently, Tenks does not have a CLI or wrapper. A virtual cluster can be
deployed by configuring the variables defined in `group_vars/*` as necessary,
then calling `ansible-playbook --inventory ansible/inventory deploy.yml`.

View File

@ -0,0 +1,90 @@
# Copyright (c) 2018 StackHPC Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# Avoid shadowing of system copy module by copy action plugin.
from __future__ import absolute_import
from copy import deepcopy
from ansible.errors import AnsibleActionFail
from ansible.module_utils._text import to_text
from ansible.plugins.action import ActionBase
import six
class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None):
"""
Schedule specifications of VMs by type onto hypervisors.
The following task vars are accepted:
:hypervisor_vars: A dict of hostvars for each hypervisor, keyed
by hypervisor hostname. Required.
:specs: A dict mapping VM type names to the number of VMs required
of that type. Required.
:vm_types: A dict mapping VM type names to a dict of properties
of that type.
:vm_name_prefix: A string with which to prefix all sequential VM
names.
:vol_name_prefix: A string with which to prefix all sequential
volume names.
:returns: A dict containing lists of VM details, keyed by the
hostname of the hypervisor to which they are scheduled.
"""
result = super(ActionModule, self).run(tmp, task_vars)
del tmp # tmp no longer has any effect
self._validate_vars(task_vars)
vms = []
idx = 0
for typ, cnt in six.iteritems(task_vars['specs']):
for _ in six.range(cnt):
vm = deepcopy(task_vars['vm_types'][typ])
# Sequentially number the VM and volume names.
vm['name'] = "%s%d" % (task_vars['vm_name_prefix'], idx)
for vol_idx, vol in enumerate(vm['volumes']):
vol['name'] = "%s%d" % (task_vars['vol_name_prefix'],
vol_idx)
vms.append(vm)
idx += 1
# TODO(w-miller): currently we just arbitrarily schedule all VMs to the
# first hypervisor. Improve this algorithm to make it more
# sophisticated.
result['result'] = {task_vars['hypervisor_vars'].keys()[0]: vms}
return result
def _validate_vars(self, task_vars):
if task_vars is None:
task_vars = {}
REQUIRED_TASK_VARS = {'hypervisor_vars', 'specs', 'vm_types'}
# Var names and their defaults.
OPTIONAL_TASK_VARS = {
('vm_name_prefix', 'vm'),
('vol_name_prefix', 'vol'),
}
for var in REQUIRED_TASK_VARS:
if var not in task_vars:
e = "The parameter '%s' must be specified." % var
raise AnsibleActionFail(to_text(e))
for var in OPTIONAL_TASK_VARS:
if var[0] not in task_vars:
task_vars[var[0]] = var[1]
if not task_vars['hypervisor_vars']:
e = ("There are no hosts in the 'hypervisors' group to which we "
"can schedule.")
raise AnsibleActionFail(to_text(e))

53
ansible/deploy.yml Normal file
View File

@ -0,0 +1,53 @@
---
- hosts: hypervisors
tasks:
- include_tasks: host_setup.yml
- hosts: libvirt
tasks:
- include_role:
name: stackhpc.libvirt-host
vars:
libvirt_host_pools:
- name: "{{ libvirt_pool_name }}"
type: "{{ libvirt_pool_type }}"
capacity: "{{ libvirt_pool_capacity }}"
path: "{{ libvirt_pool_path }}"
mode: "{{ libvirt_pool_mode }}"
owner: "{{ libvirt_pool_owner }}"
group: "{{ libvirt_pool_group }}"
libvirt_host_require_vt: "{{ libvirt_require_vt }}"
# Ensure we have facts about all hypervisors before scheduling begins.
- hosts: hypervisors
gather_facts: true
- hosts: localhost
tasks:
- include_tasks: schedule.yml
- name: Load allocations from file
include_vars:
file: "{{ allocations_file_path }}"
name: allocations
- hosts: hypervisors
tasks:
- include_tasks: vm_physical_network.yml
vars:
vm_name: "{{ item.0.name }}"
physnet: "{{ item.1 }}"
# Loop over each physical network for each VM allocated to this host.
# Allocations are stored in localhost's vars.
loop: >-
{{ hostvars['localhost'].allocations.result[inventory_hostname]
| default([]) | subelements('physical_networks') }}
- hosts: libvirt
tasks:
- include_tasks: libvirt_create_vms.yml
vars:
# Allocations are stored in the localhost's vars.
vms: >-
{{ hostvars['localhost'].allocations.result[inventory_hostname]
| default([]) }}

View File

@ -0,0 +1,70 @@
# Copyright (c) 2018 StackHPC Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from ansible.errors import AnsibleFilterError
from jinja2 import contextfilter
class FilterModule(object):
'''Libvirt configuration filters'''
def filters(self):
return {
'set_libvirt_interfaces': set_libvirt_interfaces,
'set_libvirt_volume_pool': set_libvirt_volume_pool,
}
# Lifted from kayobe:ansible/filter_plugins/networks.py
def _get_hostvar(context, var_name, inventory_hostname=None):
if inventory_hostname is None:
namespace = context
else:
if inventory_hostname not in context['hostvars']:
raise AnsibleFilterError(
"Inventory hostname '%s' not in hostvars" % inventory_hostname)
namespace = context["hostvars"][inventory_hostname]
return namespace.get(var_name)
@contextfilter
def set_libvirt_interfaces(context, vm):
"""Set interfaces for a VM's specified physical networks.
"""
physnet_mappings = _get_hostvar(context, 'physnet_mappings')
prefix = _get_hostvar(context, 'veth_prefix')
suffix = _get_hostvar(context, 'veth_vm_source_suffix')
vm['interfaces'] = []
# Libvirt doesn't need to know about physical networks, so pop them here.
for physnet in vm.pop('physical_networks', []):
# Get the ID of this physical network on the hypervisor.
idx = sorted(physnet_mappings).index(physnet)
vm['interfaces'].append(
{'type': 'direct',
# FIXME(w-miller): Don't duplicate the logic of this naming scheme
# from vm_physical_network.yml
'source': {'dev': prefix + vm['name'] + '-' + str(idx) + suffix}}
)
return vm
@contextfilter
def set_libvirt_volume_pool(context, vm):
"""Set the Libvirt volume pool for each volume.
"""
pool = _get_hostvar(context, 'libvirt_pool_name')
for vol in vm.get('volumes', []):
vol['pool'] = pool
return vm

View File

@ -0,0 +1,37 @@
---
# Map physical network names to their source device. This can be either an
# existing interface or an existing bridge.
physnet_mappings: {}
system_requirements:
- python-virtualenv
# Path to virtualenv used to install Python requirements. If a virtualenv does
# not exist at this location, one will be created.
virtualenv_path: "{{ '/'.join([ansible_env['HOME'], 'tenks-venv']) }}"
# Naming scheme for bridges created by tenks for physical networks is
# {{ bridge_prefix + i }}, where `i` is the index of the physical network in
# physnet_mappings (sorted alphabetically by key).
bridge_prefix: brtenks
# Prefix for all veth links.
veth_prefix: 'p-'
# Suffix for veth links attached to a Tenks OVS bridge.
veth_bridge_ovs_suffix: '-ovs'
# Suffix for veth links attached to a source Linux bridge.
veth_bridge_source_suffix: '-phy'
# Suffix for veth links attached to a Tenks OVS bridge.
veth_vm_ovs_suffix: '-ovs'
# Suffix for veth links attached to a VM. VMs aren't physical so '-phy' doesn't
# seem right.
veth_vm_source_suffix: '-tap'
console_log_directory: /var/log/tenks/console_logs/
# The URL of the upper constraints file to pass to pip when installing Python
# packages.
python_upper_contraints_url: >-
https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt

View File

@ -0,0 +1,13 @@
---
libvirt_pool_name: tenks
libvirt_pool_path: /var/lib/libvirt/tenks_pool/
libvirt_pool_type: dir
# Capacity is irrelevant for directory-based pools.
libvirt_pool_capacity:
libvirt_pool_mode: 755
libvirt_pool_owner: "{{ ansible_user_id }}"
libvirt_pool_group: "{{ ansible_user_id }}"
# By default, allow QEMU without hardware virtualisation since this is a
# development tool.
libvirt_require_vt: false

53
ansible/host_setup.yml Normal file
View File

@ -0,0 +1,53 @@
---
- name: Ensure general system requirements are installed
yum:
name: "{{ system_requirements }}"
become: true
- name: Ensure console log directory exists
file:
path: "{{ console_log_directory }}"
state: directory
become: true
- name: Check if ovs-vsctl command is present
command: ovs-vsctl --version
register: ovs_vsctl_check
failed_when: false
changed_when: false
- block:
- name: Ensure Open vSwitch package is installed
yum:
name: openvswitch
become: true
- name: Ensure Open vSwitch is started and enabled
service:
name: openvswitch
state: running
enabled: true
become: true
# Return code 127 means the command does not exist. Do this check to avoid
# installing Open vSwitch system-wide if the command already exists as a link
# to a containerised version of OVS.
when: ovs_vsctl_check.rc == 127
- name: Configure physical networks
include_tasks: physical_network.yml
vars:
network_name: "{{ item.0 }}"
tenks_bridge: "{{ bridge_prefix ~ idx }}"
source_interface: "{{ item.1 }}"
# Sort to ensure we always enumerate in the same order.
loop: "{{ physnet_mappings | dictsort }}"
loop_control:
index_var: idx
- name: Ensure Python requirements are installed
pip:
requirements: >-
{{ '/'.join([(playbook_dir | dirname), 'venv-requirements.txt']) }}
extra_args: >-
-c {{ python_upper_contraints_url }}
virtualenv: "{{ virtualenv_path }}"

View File

@ -0,0 +1,33 @@
---
allocations_file_path: >-
{{ '/'.join([(playbook_dir | dirname), 'allocations.yml']) }}
# vm_types is a dict that defines different sets of VM specifications, keyed by
# a 'VM type name' to associate with each set of specifications. An example of
# the format of this variable is below:
#
# vm_types:
# # The type name.
# type0:
# # The amount of RAM, in mebibytes.
# memory_mb: 1024
# # The number of virtual CPUs.
# vcpus: 2
# # A list of volumes, each with a capacity.
# volumes:
# - capacity: 2GB
# # A list of physical network names to connect to. These physical network
# # names should be keyed in `physnet_mappings` in each hypervisor's host
# # vars.
# physical_networks:
# - physnet1
vm_types: {}
# specs is a dict that maps different VM type names (define in `vm_types`
# above) to the number of VMs of that type that are to be created. Only VM
# types which you want to create VMs from need be keyed here. For example:
#
# specs:
# # Create four VMs with the specifications defined in `vm_types` by 'type0'.
# type0: 4
specs: {}

5
ansible/inventory/groups Normal file
View File

@ -0,0 +1,5 @@
[hypervisors:children]
libvirt
[libvirt]
# Empty group to provide declaration of libvirt group.

2
ansible/inventory/hosts Normal file
View File

@ -0,0 +1,2 @@
[libvirt]
localhost ansible_connection=local

View File

@ -0,0 +1,11 @@
---
- name: Create VM
include_role:
name: stackhpc.libvirt-vm
vars:
libvirt_vm_default_console_log_dir: "{{ console_log_directory }}"
# Configure VM definitions for the Libvirt provider.
libvirt_vms: >-
{{ vms | map('set_libvirt_interfaces')
| map('set_libvirt_volume_pool')
| list }}

View File

@ -0,0 +1,86 @@
---
- name: Fail if source interface does not exist
fail:
msg: >
The interface {{ source_interface }} specified for the physical network
{{ network_name }} does not exist.
when: source_interface not in ansible_interfaces
### Firstly, some fact gathering.
# Start off by assuming the source interface is direct, unless proven
# otherwise.
- set_fact:
source_type: direct
- name: Get source interface details
command: ip -details link show {{ source_interface }}
register: if_details
changed_when: false
- name: Register source interface as a Linux bridge
set_fact:
source_type: linux_bridge
when: if_details.stdout_lines[-1].split()[0] == 'bridge'
- block:
- name: Get list of OVS bridges
command: ovs-vsctl list-br
register: ovs_bridges
changed_when: false
- name: Register source interface as an Open vSwitch bridge
set_fact:
source_type: ovs_bridge
when: source_interface in ovs_bridges.stdout_lines
when: if_details.stdout_lines[-1].split()[0] == 'openvswitch'
### Actual configuration starts here.
- name: Ensure Open vSwitch bridge exists
openvswitch_bridge:
bridge: "{{ tenks_bridge }}"
- name: Connect to existing Linux bridge
when: source_type == 'linux_bridge'
include_role:
name: veth-pair
vars:
veth_pair_ovs_bridge: "{{ tenks_bridge }}"
veth_pair_ovs_link_name: >-
{{ veth_prefix + tenks_bridge + veth_bridge_ovs_suffix }}
veth_pair_source_bridge: "{{ source_interface }}"
veth_pair_source_link_name: >-
{{ veth_prefix + tenks_bridge + veth_bridge_source_suffix }}
plug_into_source: true
- name: Connect to existing Open vSwitch bridge
when: source_type == 'ovs_bridge'
block:
- name: Create patch port on Tenks bridge
openvswitch_port:
bridge: "{{ tenks_bridge }}"
port: "{{ veth_prefix + tenks_bridge + veth_bridge_ovs_suffix }}"
# Despite the module documentation, `set` will happily take multiple
# properties.
set: >-
Interface {{ veth_prefix + tenks_bridge + veth_bridge_ovs_suffix }}
type=patch
options:peer={{ veth_prefix + tenks_bridge +
veth_bridge_source_suffix }}
- name: Create patch port on source bridge
openvswitch_port:
bridge: "{{ source_interface }}"
port: "{{ veth_prefix + tenks_bridge + veth_bridge_source_suffix }}"
set: >-
Interface {{ veth_prefix + tenks_bridge + veth_bridge_source_suffix }}
type=patch
options:peer={{ veth_prefix + tenks_bridge +
veth_bridge_ovs_suffix }}
- name: Plug source interface into Tenks bridge
when: source_type == 'direct'
openvswitch_port:
bridge: "{{ tenks_bridge }}"
port: "{{ source_interface }}"

View File

@ -0,0 +1,26 @@
Veth Pair
=========
This role creates a veth pair. It will plug one end into the specified OVS
bridge and, optionally, can plug the other end into a source Linux bridge.
Requirements
------------
The host should have the `ip` and `ovs-vsctl` commands accessible. If
`veth_pair_plug_into_source` is enabled, the command `brctl` must also be
accessible.
Role Variables
--------------
- `veth_pair_ovs_link_name`: The name to give the veth link that plugs into the
OVS bridge.
- `veth_pair_ovs_bridge`: The name of the OVS bridge to plug into.
- `veth_pair_source_link_name`: The name to give the veth link that plugs into
the source device.
- `veth_pair_source_bridge`: The name of the source Linux bridge to plug into. Must be
specified if and only if `veth_pair_plug_into_source` is enabled.
- `veth_pair_plug_into_source`: Whether or not to plug the source end of the
veth pair into a Linux bridge. If enabled, `veth_pair_source_bridge` must
also be specified. Default is `false`.

View File

@ -0,0 +1,3 @@
---
# Whether or not to plug the source end of the veth pair into a Linux bridge.
veth_pair_plug_into_source: false

View File

@ -0,0 +1,29 @@
---
- name: Create veth pair
command: >-
ip link add dev {{ veth_pair_ovs_link_name }}
type veth
peer name {{ veth_pair_source_link_name }}
register: res
changed_when: res.rc == 0
# Return code 2 means the veth pair already exists
failed_when: res.rc not in [0, 2]
become: true
- name: Plug veth into OVS bridge
openvswitch_port:
bridge: "{{ veth_pair_ovs_bridge }}"
port: "{{ veth_pair_ovs_link_name }}"
become: true
- name: Plug veth into source bridge
command: >-
brctl addif {{ veth_pair_source_bridge }}
{{ veth_pair_source_link_name }}
register: res
failed_when:
- res.rc != 0
- "'already a member of a bridge' not in res.stderr"
changed_when: "'already a member of a bridge' not in res.stderr"
when: veth_pair_plug_into_source | bool
become: true

22
ansible/schedule.yml Normal file
View File

@ -0,0 +1,22 @@
---
# Creates a dict mapping each hypervisor's hostname to its hostvars, to be used
# during scheduling.
- name: Collect hypervisor hostvars
set_fact:
hypervisor_vars: >-
{{ hypervisor_vars | default({}) | combine({item: hostvars[item]}) }}
loop: "{{ groups['hypervisors'] }}"
- name: Schedule VMs to hypervisors
tenks_schedule:
hypervisor_vars: "{{ hypervisor_vars }}"
vm_types: "{{ vm_types }}"
specs: "{{ specs }}"
register: allocations
- name: Write VM allocations to file
copy:
# tenks_schedule lookup plugin outputs a dict. Pretty-print this to persist
# it in a YAML file.
content: "{{ allocations.result | to_nice_yaml }}"
dest: "{{ allocations_file_path }}"

View File

@ -0,0 +1,28 @@
---
- name: Gather details for VM physical network connection
block:
- name: Get the physical network index
set_fact:
# The index of the physical network within this hypervisor's physical
# networks.
idx: >-
{{ (physnet_mappings | dictsort | list).index(
(physnet, physnet_mappings[physnet])) }}
- name: Set VM veth base name
set_fact:
# Veth pairs are unique for any VM-physnet combination. However, device
# names cannot be longer than 15 characters, so use physical networks'
# indices instead.
veth_base_name: >-
{{ veth_prefix + vm_name + '-' + idx }}
- name: Set up veth pairs for the VM
include_role:
name: veth-pair
vars:
veth_pair_ovs_bridge: >-
{{ bridge_prefix ~ idx }}
veth_pair_ovs_link_name: "{{ veth_base_name + veth_vm_ovs_suffix }}"
veth_pair_source_link_name: >-
{{ veth_base_name + veth_vm_source_suffix }}

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
# The order of packages is significant, because pip processes them in the order
# of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later.
pbr>=2.0 # Apache-2.0
ansible>=2.6.0 # GPLv3

3
requirements.yml Normal file
View File

@ -0,0 +1,3 @@
---
- src: stackhpc.libvirt-host
- src: stackhpc.libvirt-vm

20
setup.cfg Normal file
View File

@ -0,0 +1,20 @@
[metadata]
name = tenks
summary = Deployment of virtual bare metal clusters with Tenks
description-file =
README.md
author = Will Miller
author-email = willm@stackhpc.com
home-page = https://stackhpc.com
classifier =
Environment :: OpenStack
Intended Audience :: Information Technology
Intended Audience :: System Administrators
Operating System :: POSIX :: Linux
Programming Language :: Python
Programming Language :: Python :: 2
Programming Language :: Python :: 2.7
[files]
packages =
tenks

29
setup.py Normal file
View File

@ -0,0 +1,29 @@
# Copyright (c) 2018 StackHPC Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT
import setuptools
# In python < 2.7.4, a lazy loading of package `pbr` will break
# setuptools if some other modules registered functions in `atexit`.
# solution from: http://bugs.python.org/issue15881#msg170215
try:
import multiprocessing # noqa
except ImportError:
pass
setuptools.setup(
setup_requires=['pbr'],
pbr=True)

6
test-requirements.txt Normal file
View File

@ -0,0 +1,6 @@
# The order of packages is significant, because pip processes them in the order
# of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later.
ansible-lint>=3.0.0 # MIT
flake8>=3.5.0 # MIT

46
tox.ini Normal file
View File

@ -0,0 +1,46 @@
[tox]
minversion = 2.0
envlist = py35,py27,pep8,alint
skipsdist = True
[testenv]
usedevelop = True
install_command = pip install {opts} {packages}
passenv =
HOME
whitelist_externals =
bash
rm
setenv =
VIRTUAL_ENV={envdir}
PYTHONWARNINGS=default::DeprecationWarning
OS_STDOUT_CAPTURE=1
OS_STDERR_CAPTURE=1
OS_TEST_TIMEOUT=60
deps =
-c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt?h=stable/rocky}
-r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
[testenv:pep8]
basepython = python3
commands =
flake8 {posargs}
[testenv:alint]
basepython = python3
# ansible-lint doesn't support custom modules, so add ours to the Ansible path.
setenv = ANSIBLE_LIBRARY = {toxinidir}/ansible/action_plugins/
# Exclude roles downloaded from Galaxy (in the form 'author.role') from
# linting.
commands = bash -c "ansible-lint \
$(find {toxinidir}/ansible -path '*.yml' \
-not -path '{toxinidir}/ansible/roles/*.*/*' -print)"
[flake8]
# E123, E125 skipped as they are invalid PEP-8.
show-source = True
ignore = E123,E125
builtins = _
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build

4
venv-requirements.txt Normal file
View File

@ -0,0 +1,4 @@
# This file contains the Python packages that are needed in the Tenks virtual
# env.
virtualbmc>=1.4.0 # Apache