Add commands to enable and disable the serial console

This allows you to access the serial console from within
Horizon.

Change-Id: Id40e72047174fc0c0c565871f24b775b30e83825
Story: 2004192
Task: 27682
This commit is contained in:
Will Szumski 2018-10-26 16:19:35 +01:00
parent 60ecee2b25
commit 4867c91481
13 changed files with 645 additions and 0 deletions

View File

@ -0,0 +1,130 @@
---
# This playbook will enable a serial console on all ironic nodes. This
# will allow you to access the serial console from within Horizon.
# See: https://docs.openstack.org/ironic/latest/admin/console.html
- name: Setup OpenStack Environment
hosts: controllers[0]
gather_facts: False
vars:
venv: "{{ virtualenv_path }}/openstack-cli"
pre_tasks:
- name: Set up openstack cli virtualenv
pip:
virtualenv: "{{ venv }}"
name:
- python-openstackclient
- python-ironicclient
- block:
- name: Fail if allocation pool start not defined
fail:
msg: >
The variable, ironic_serial_console_tcp_pool_start is not defined.
This variable is required to run this playbook.
when: not ironic_serial_console_tcp_pool_start
- name: Fail if allocation pool end not defined
fail:
msg: >
The variable, ironic_serial_console_tcp_pool_end is not defined.
This variable is required to run this playbook.
when:
- not ironic_serial_console_tcp_pool_end
- name: Get list of nodes that we should configure serial consoles on
set_fact:
baremetal_nodes: >-
{{ query('inventory_hostnames', console_compute_node_limit |
default('baremetal-compute') ) | unique }}
- name: Reserve TCP ports for ironic serial consoles
include_role:
name: console-allocation
vars:
console_allocation_pool_start: "{{ ironic_serial_console_tcp_pool_start }}"
console_allocation_pool_end: "{{ ironic_serial_console_tcp_pool_end }}"
console_allocation_ironic_nodes: "{{ baremetal_nodes }}"
console_allocation_filename: "{{ kayobe_config_path }}/console-allocation.yml"
when: cmd == "enable"
- name: Enable serial console
hosts: "{{ console_compute_node_limit | default('baremetal-compute') }}"
gather_facts: False
vars:
venv: "{{ virtualenv_path }}/openstack-cli"
controller_host: "{{ groups['controllers'][0] }}"
tasks:
- name: Get list of nodes
command: >
{{ venv }}/bin/openstack baremetal node list -f json --long
register: nodes
delegate_to: "{{ controller_host }}"
environment: "{{ openstack_auth_env }}"
run_once: true
changed_when: false
vars:
# NOTE: Without this, the controller's ansible_host variable will not
# be respected when using delegate_to.
ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}"
- block:
- name: Fail if console interface is not ipmitool-socat
fail:
msg: >-
In order to use the serial console you must set the console_interface to ipmitool-socat.
when: node["Console Interface"] != "ipmitool-socat"
- name: Set IPMI serial console terminal port
vars:
name: "{{ node['Name'] }}"
port: "{{ hostvars[controller_host].console_allocation_result.ports[name] }}"
# NOTE: Without this, the controller's ansible_host variable will not
# be respected when using delegate_to.
ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}"
command: >
{{ venv }}/bin/openstack baremetal node set {{ name }} --driver-info ipmi_terminal_port={{ port }}
delegate_to: "{{ controller_host }}"
environment: "{{ openstack_auth_env }}"
when: >-
node['Driver Info'].ipmi_terminal_port is not defined or
node['Driver Info'].ipmi_terminal_port | int != port | int
- name: Enable the IPMI socat serial console
vars:
# NOTE: Without this, the controller's ansible_host variable will not
# be respected when using delegate_to.
ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}"
command: >
{{ venv }}/bin/openstack baremetal node console enable {{ node['Name'] }}
delegate_to: "{{ controller_host }}"
environment: "{{ openstack_auth_env }}"
when: not node['Console Enabled']
vars:
matching_nodes: >-
{{ (nodes.stdout | from_json) | selectattr('Name', 'defined') |
selectattr('Name', 'equalto', inventory_hostname ) | list }}
node: "{{ matching_nodes | first }}"
when:
- cmd == "enable"
- matching_nodes | length > 0
- block:
- name: Disable the IPMI socat serial console
vars:
# NOTE: Without this, the controller's ansible_host variable will not
# be respected when using delegate_to.
ansible_host: "{{ hostvars[controller_host].ansible_host | default(controller_host) }}"
command: >
{{ venv }}/bin/openstack baremetal node console disable {{ node['Name'] }}
delegate_to: "{{ controller_host }}"
environment: "{{ openstack_auth_env }}"
when: node['Console Enabled']
vars:
matching_nodes: >-
{{ (nodes.stdout | from_json) | selectattr('Name', 'defined') |
selectattr('Name', 'equalto', inventory_hostname ) | list }}
node: "{{ matching_nodes | first }}"
when:
- cmd == "disable"
- matching_nodes | length > 0

View File

@ -132,3 +132,16 @@ kolla_ironic_pxe_append_params_extra: []
kolla_ironic_pxe_append_params: >
{{ kolla_ironic_pxe_append_params_default +
kolla_ironic_pxe_append_params_extra }}
###############################################################################
# Ironic Node Configuration
# This defines the start of the range of TCP ports to used for the IPMI socat
# serial consoles
ironic_serial_console_tcp_pool_start: 30000
# This defines the end of the range of TCP ports to used for the IPMI socat
# serial consoles
ironic_serial_console_tcp_pool_end: 31000
###############################################################################

View File

@ -0,0 +1,13 @@
---
# Path to file in which to store console allocations.
console_allocation_filename:
# List of Names or UUIDs corresponding to Ironic nodes that you want to allocate
# serial consoles for
console_allocation_ironic_nodes: []
# allocation_pool_start: First TCP port in the allocation pool
console_allocation_pool_start:
# allocation_pool_end: Last TCP port in the allocation pool
console_allocation_pool_end:

View File

@ -0,0 +1,192 @@
#!/usr/bin/python
# Copyright (c) 2017 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.
# TODO(wszumski): If we have multiple conductors and they are on different machines
# we could make a pool per machine.
DOCUMENTATION = """
module: console_allocation
short_description: Allocate a serial console TCP port for an Ironic node from a pool
author: Mark Goddard (mark@stackhpc.com) and Will Szumski (will@stackhpc.com)
options:
- option-name: nodes
description: List of Names or UUIDs corresponding to Ironic Nodes
required: True
type: list
- option-name: allocation_pool_start
description: First address of the pool from which to allocate
required: True
type: int
- option-name: allocation_pool_end
description: Last address of the pool from which to allocate
required: True
type: int
- option-name: allocation_file
description: >
Path to a file in which to store the allocations. Will be created if it
does not exist.
required: True
type: string
requirements:
- PyYAML
"""
EXAMPLES = """
- name: Ensure Ironic node has a TCP port assigned for it's serial console
console_allocation:
nodes: ['node-1', 'node-2']
allocation_pool_start: 30000
allocation_pool_end: 31000
allocation_file: /path/to/allocation/file.yml
"""
RETURN = """
ports:
description: >
A dictionary mapping the node name to the allocated serial console TCP port
returned: success
type: dict
sample: { 'node1' : 30000, 'node2':300001 }
"""
from ansible.module_utils.basic import *
import sys
# Store a list of import errors to report to the user.
IMPORT_ERRORS=[]
try:
import yaml
except Exception as e:
IMPORT_ERRORS.append(e)
def read_allocations(module):
"""Read TCP port allocations from the allocation file."""
filename = module.params['allocation_file']
try:
with open(filename, 'r') as f:
content = yaml.load(f)
except IOError as e:
if e.errno == errno.ENOENT:
# Ignore ENOENT - we will create the file.
return {}
module.fail_json(msg="Failed to open allocation file %s for reading" % filename)
except yaml.YAMLError as e:
module.fail_json(msg="Failed to parse allocation file %s as YAML" % filename)
if content is None:
# If the file is empty, yaml.load() will return None.
content = {}
return content
def write_allocations(module, allocations):
"""Write TCP port allocations to the allocation file."""
filename = module.params['allocation_file']
try:
with open(filename, 'w') as f:
yaml.dump(allocations, f, default_flow_style=False)
except IOError as e:
module.fail_json(msg="Failed to open allocation file %s for writing" % filename)
except yaml.YAMLError as e:
module.fail_json(msg="Failed to dump allocation file %s as YAML" % filename)
def is_valid_port(port):
try:
int(port)
except ValueError:
return False
if port < 0:
return False
if port > 65535:
return False
return True
def update_allocation(module, allocations):
"""Allocate a TCP port of an Ironic serial console.
:param module: AnsibleModule instance
:param allocations: Existing IP address allocations
"""
nodes = module.params['nodes']
allocation_pool_start = module.params['allocation_pool_start']
allocation_pool_end = module.params['allocation_pool_end']
result = {
'changed': False,
'ports': {}
}
object_name = "serial_console_allocations"
console_allocations = allocations.setdefault(object_name, {})
invalid_allocations = {node: port for node, port in console_allocations.items()
if not is_valid_port(port)}
if invalid_allocations:
module.fail_json(msg="Found invalid existing allocations in %s: %s" %
(object_name,
", ".join("%s: %s" % (node, port)
for node, port in invalid_allocations.items())))
allocated_consoles = { int(x) for x in console_allocations.values() }
allocation_pool = { x for x in range(allocation_pool_start, allocation_pool_end + 1) }
free_ports = list(allocation_pool - allocated_consoles)
free_ports.sort(reverse=True)
for node in nodes:
if node not in console_allocations:
if len(free_ports) < 1:
module.fail_json(msg="No unallocated TCP ports for %s in %s" % (node, object_name))
result['changed'] = True
free_port = free_ports.pop()
console_allocations[node] = free_port
result['ports'][node] = console_allocations[node]
return result
def allocate(module):
"""Allocate a TCP port an ironic serial console, updating the allocation file."""
allocations = read_allocations(module)
result = update_allocation(module, allocations)
if result['changed'] and not module.check_mode:
write_allocations(module, allocations)
return result
def main():
module = AnsibleModule(
argument_spec=dict(
nodes=dict(required=True, type='list'),
allocation_pool_start=dict(required=True, type='int'),
allocation_pool_end=dict(required=True, type='int'),
allocation_file=dict(required=True, type='str'),
),
supports_check_mode=True,
)
# Fail if there were any exceptions when importing modules.
if IMPORT_ERRORS:
module.fail_json(msg="Import errors: %s" %
", ".join([repr(e) for e in IMPORT_ERRORS]))
try:
results = allocate(module)
except Exception as e:
module.fail_json(msg="Failed to allocate TCP port: %s" % repr(e))
else:
module.exit_json(**results)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,73 @@
---
# Facts may not be available for the Ansible control host, so read the OS
# release manually.
- name: Check the OS release
local_action:
module: shell . /etc/os-release && echo $ID
changed_when: False
register: console_allocation_os_release
- name: Include RedHat family-specific variables
include_vars: "RedHat.yml"
when: console_allocation_os_release.stdout in ['centos', 'fedora', 'rhel']
- name: Include Debian family-specific variables
include_vars: "Debian.yml"
when: console_allocation_os_release.stdout in ['debian', 'ubuntu']
# Note: Currently we install these using the system package manager rather than
# pip to a virtualenv. This is because Yum is required elsewhere and cannot
# easily be installed in a virtualenv.
- name: Ensure package dependencies are installed
local_action:
module: package
name: "{{ item }}"
state: installed
use: "{{ console_allocation_package_manager }}"
become: True
with_items: "{{ console_allocation_package_dependencies }}"
run_once: True
- name: Validate allocation pool start
vars:
port: "{{ console_allocation_pool_start | int(default=-1) }}"
fail:
msg: >-
You must must define an console_allocation_pool_start. This should
be a valid TCP port.
when: >-
console_allocation_pool_end is none or
port | int < 0 or port | int > 65535
- name: Validate allocation pool end
vars:
port: "{{ console_allocation_pool_end | int(default=-1) }}"
fail:
msg: >-
You must must define an console_allocation_pool_end. This should
be a valid TCP port.
when: >-
console_allocation_pool_end is none or
port | int < 0 or port | int > 65535
- name: Validate that allocation start is less than allocation end
fail:
msg: >-
console_allocation_start and console_allocation_end define a range
of TCP ports. You have defined a range with a start that is less than
the end
when:
- (console_allocation_pool_start | int) > (console_allocation_pool_end | int)
- name: Ensure Ironic serial console ports are allocated
local_action:
module: console_allocation
allocation_file: "{{ console_allocation_filename }}"
nodes: "{{ console_allocation_ironic_nodes }}"
allocation_pool_start: "{{ console_allocation_pool_start }}"
allocation_pool_end: "{{ console_allocation_pool_end }}"
register: result
- name: Register a fact containing the console allocation result
set_fact:
console_allocation_result: "{{ result }}"

View File

@ -0,0 +1,7 @@
---
# Package manager to use.
console_allocation_package_manager: apt
# List of packages to install.
console_allocation_package_dependencies:
- python-yaml

View File

@ -0,0 +1,7 @@
---
# Package manager to use.
console_allocation_package_manager: yum
# List of packages to install.
console_allocation_package_dependencies:
- PyYAML

View File

@ -203,6 +203,53 @@ according to their inventory host names, you can run the following command::
This command will use the ``ipmi_address`` host variable from the inventory
to map the inventory host name to the correct node.
Ironic Serial Console
---------------------
To access the baremetal nodes from within Horizon you need to enable the serial
console. For this to work the you must set ``kolla_enable_nova_serialconsole_proxy``
to ``true`` in ``etc/kayobe/kolla.yml``::
kolla_enable_nova_serialconsole_proxy: true
The console interface on the Ironic nodes is expected to be ``ipmitool-socat``, you
can check this with::
openstack baremetal node show <node_id> --fields console_interface
where <node_id> should be the UUID or name of the Ironic node you want to check.
If you have set ``kolla_ironic_enabled_console_interfaces`` in ``etc/kayobe/ironic.yml``,
it should include ``ipmitool-socat`` in the list of enabled interfaces.
The playbook to enable the serial console currently only works if the Ironic node
name matches the inventory hostname.
Once these requirements have been satisfied, you can run::
(kayobe) $ kayobe baremetal compute serial console enable
This will reserve a TCP port for each node to use for the serial console interface.
The allocations are stored in ``${KAYOBE_CONFIG_PATH}/console-allocation.yml``. The
current implementation uses a global pool, which is specified by
``ironic_serial_console_tcp_pool_start`` and ``ironic_serial_console_tcp_pool_end``;
these variables can set in ``etc/kayobe/ironic.yml``.
To disable the serial console you can use::
(kayobe) $ kayobe baremetal compute serial console disable
The port allocated for each node is retained and must be manually removed from
``${KAYOBE_CONFIG_PATH}/console-allocation.yml`` if you want it to be reused by another
Ironic node with a different name.
You can optionally limit the nodes targeted by setting ``baremetal-compute-limit``::
(kayobe) $ kayobe baremetal compute serial console enable --baremetal-compute-limit sand-6-1
which should take the form of an `ansible host pattern <https://docs.ansible.com/ansible/latest/user_guide/intro_patterns.html>`_.
.. _update_deployment_image:
Update Deployment Image

View File

@ -106,6 +106,17 @@
# List of kernel parameters to append for baremetal PXE boot.
#kolla_ironic_pxe_append_params:
###############################################################################
# Ironic Node Configuration
# This defines the start of the range of TCP ports to used for the IPMI socat
# serial consoles
#ironic_serial_console_tcp_pool_start:
# This defines the end of the range of TCP ports to used for the IPMI socat
# serial consoles
#ironic_serial_console_tcp_pool_end:
###############################################################################
# Dummy variable to allow Ansible to accept this file.
workaround_ansible_issue_8743: yes

View File

@ -1247,6 +1247,57 @@ class BaremetalComputeRename(KayobeAnsibleMixin, VaultMixin, Command):
self.run_kayobe_playbooks(parsed_args, playbooks)
class BaremetalComputeSerialConsoleBase(KayobeAnsibleMixin, VaultMixin,
Command):
"""Base class for the baremetal serial console commands"""
@staticmethod
def process_limit(parsed_args, extra_vars):
if parsed_args.baremetal_compute_limit:
extra_vars["console_compute_node_limit"] = (
parsed_args.baremetal_compute_limit
)
def get_parser(self, prog_name):
parser = super(BaremetalComputeSerialConsoleBase, self).get_parser(
prog_name)
group = parser.add_argument_group("Baremetal Serial Consoles")
group.add_argument("--baremetal-compute-limit",
help="Limit the change to the hosts specified in "
"this limit"
)
return parser
class BaremetalComputeSerialConsoleEnable(BaremetalComputeSerialConsoleBase):
"""Enable Serial Console for Baremetal Compute Nodes"""
def take_action(self, parsed_args):
self.app.LOG.debug("Enabling serial console for ironic nodes")
extra_vars = {}
BaremetalComputeSerialConsoleBase.process_limit(parsed_args,
extra_vars)
extra_vars["cmd"] = "enable"
playbooks = _build_playbook_list("baremetal-compute-serial-console")
self.run_kayobe_playbooks(parsed_args, playbooks,
extra_vars=extra_vars)
class BaremetalComputeSerialConsoleDisable(BaremetalComputeSerialConsoleBase):
"""Disable Serial Console for Baremetal Compute Nodes"""
def take_action(self, parsed_args):
self.app.LOG.debug("Disable serial console for ironic nodes")
extra_vars = {}
BaremetalComputeSerialConsoleBase.process_limit(parsed_args,
extra_vars)
extra_vars["cmd"] = "disable"
playbooks = _build_playbook_list("baremetal-compute-serial-console")
self.run_kayobe_playbooks(parsed_args, playbooks,
extra_vars=extra_vars)
class BaremetalComputeUpdateDeploymentImage(KayobeAnsibleMixin, VaultMixin,
Command):
"""Update the Ironic nodes to use the new kernel and ramdisk images."""

View File

@ -1150,6 +1150,100 @@ class TestCase(unittest.TestCase):
]
self.assertEqual(expected_calls, mock_run.call_args_list)
@mock.patch.object(commands.KayobeAnsibleMixin,
"run_kayobe_playbooks")
def test_baremetal_compute_serial_console_enable(self, mock_run):
command = commands.BaremetalComputeSerialConsoleEnable(TestApp(), [])
parser = command.get_parser("test")
parsed_args = parser.parse_args([])
result = command.run(parsed_args)
self.assertEqual(0, result)
expected_calls = [
mock.call(
mock.ANY,
[
"ansible/baremetal-compute-serial-console.yml",
],
extra_vars={
"cmd": "enable",
}
),
]
self.assertEqual(expected_calls, mock_run.call_args_list)
@mock.patch.object(commands.KayobeAnsibleMixin,
"run_kayobe_playbooks")
def test_baremetal_compute_serial_console_enable_with_limit(self,
mock_run):
command = commands.BaremetalComputeSerialConsoleEnable(TestApp(), [])
parser = command.get_parser("test")
parsed_args = parser.parse_args(["--baremetal-compute-limit",
"sand-6-1"])
result = command.run(parsed_args)
self.assertEqual(0, result)
expected_calls = [
mock.call(
mock.ANY,
[
"ansible/baremetal-compute-serial-console.yml",
],
extra_vars={
"cmd": "enable",
"console_compute_node_limit": "sand-6-1",
}
),
]
self.assertEqual(expected_calls, mock_run.call_args_list)
@mock.patch.object(commands.KayobeAnsibleMixin,
"run_kayobe_playbooks")
def test_baremetal_compute_serial_console_disable(self, mock_run):
command = commands.BaremetalComputeSerialConsoleDisable(TestApp(), [])
parser = command.get_parser("test")
parsed_args = parser.parse_args([])
result = command.run(parsed_args)
self.assertEqual(0, result)
expected_calls = [
mock.call(
mock.ANY,
[
"ansible/baremetal-compute-serial-console.yml",
],
extra_vars={
"cmd": "disable",
}
),
]
self.assertEqual(expected_calls, mock_run.call_args_list)
@mock.patch.object(commands.KayobeAnsibleMixin,
"run_kayobe_playbooks")
def test_baremetal_compute_serial_console_disable_with_limit(self,
mock_run):
command = commands.BaremetalComputeSerialConsoleDisable(TestApp(), [])
parser = command.get_parser("test")
parsed_args = parser.parse_args(["--baremetal-compute-limit",
"sand-6-1"])
result = command.run(parsed_args)
self.assertEqual(0, result)
expected_calls = [
mock.call(
mock.ANY,
[
"ansible/baremetal-compute-serial-console.yml",
],
extra_vars={
"cmd": "disable",
"console_compute_node_limit": "sand-6-1",
}
),
]
self.assertEqual(expected_calls, mock_run.call_args_list)
@mock.patch.object(commands.KayobeAnsibleMixin,
"run_kayobe_playbooks")
def test_baremetal_compute_update_deployment_image(self, mock_run):

View File

@ -0,0 +1,5 @@
---
features:
- |
Added commands to enable and disable the Ironic serial console.
This allows you to use the serial console from within Horizon.

View File

@ -33,6 +33,8 @@ kayobe.cli=
baremetal_compute_provide = kayobe.cli.commands:BaremetalComputeProvide
baremetal_compute_rename = kayobe.cli.commands:BaremetalComputeRename
baremetal_compute_update_deployment_image = kayobe.cli.commands:BaremetalComputeUpdateDeploymentImage
baremetal_compute_serial_console_enable = kayobe.cli.commands:BaremetalComputeSerialConsoleEnable
baremetal_compute_serial_console_disable = kayobe.cli.commands:BaremetalComputeSerialConsoleDisable
control_host_bootstrap = kayobe.cli.commands:ControlHostBootstrap
control_host_upgrade = kayobe.cli.commands:ControlHostUpgrade
configuration_dump = kayobe.cli.commands:ConfigurationDump