diff --git a/ansible/baremetal-compute-serial-console.yml b/ansible/baremetal-compute-serial-console.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b4783b728fa591cd0f22ea6f0ccdd1bc7a7b1b48
--- /dev/null
+++ b/ansible/baremetal-compute-serial-console.yml
@@ -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
diff --git a/ansible/group_vars/all/ironic b/ansible/group_vars/all/ironic
index f950b9c6ed8029d094949b296de3d9681ed3b074..f70d24ccd66769a25c22a7ae4294ffb54917fe90 100644
--- a/ansible/group_vars/all/ironic
+++ b/ansible/group_vars/all/ironic
@@ -127,3 +127,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
+
+###############################################################################
diff --git a/ansible/roles/console-allocation/defaults/main.yml b/ansible/roles/console-allocation/defaults/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d3743fb332818624500e15419f56e1f568de9d0b
--- /dev/null
+++ b/ansible/roles/console-allocation/defaults/main.yml
@@ -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:
diff --git a/ansible/roles/console-allocation/library/console_allocation.py b/ansible/roles/console-allocation/library/console_allocation.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb4f7bc458d91341b02429bc04a968f367eab02b
--- /dev/null
+++ b/ansible/roles/console-allocation/library/console_allocation.py
@@ -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()
diff --git a/ansible/roles/console-allocation/tasks/main.yml b/ansible/roles/console-allocation/tasks/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2d3da89c3420f91bbf38ecde8b08a6eff0bd7254
--- /dev/null
+++ b/ansible/roles/console-allocation/tasks/main.yml
@@ -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 }}"
diff --git a/ansible/roles/console-allocation/vars/Debian.yml b/ansible/roles/console-allocation/vars/Debian.yml
new file mode 100644
index 0000000000000000000000000000000000000000..de0361a9602c456d88e5be8ae6dd13f283bd6210
--- /dev/null
+++ b/ansible/roles/console-allocation/vars/Debian.yml
@@ -0,0 +1,7 @@
+---
+# Package manager to use.
+console_allocation_package_manager: apt
+
+# List of packages to install.
+console_allocation_package_dependencies:
+  - python-yaml
diff --git a/ansible/roles/console-allocation/vars/RedHat.yml b/ansible/roles/console-allocation/vars/RedHat.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fe9989bb9aa48981b187779eedb7ffcc3355382a
--- /dev/null
+++ b/ansible/roles/console-allocation/vars/RedHat.yml
@@ -0,0 +1,7 @@
+---
+# Package manager to use.
+console_allocation_package_manager: yum
+
+# List of packages to install.
+console_allocation_package_dependencies:
+  - PyYAML
diff --git a/doc/source/administration.rst b/doc/source/administration.rst
index dc9887ce365dbec4a393bf3f6bfd330502e1fb94..47a3f3d044e91f243bb5190e4b3287016a7098c4 100644
--- a/doc/source/administration.rst
+++ b/doc/source/administration.rst
@@ -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
diff --git a/etc/kayobe/ironic.yml b/etc/kayobe/ironic.yml
index 948cd3480cf7fda3d2a0cf327fb9db70659dbc07..4c0a0e42987a4456e7451d45b517a7279f3233b7 100644
--- a/etc/kayobe/ironic.yml
+++ b/etc/kayobe/ironic.yml
@@ -103,6 +103,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
diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py
index c44a0d19e7d370ed81d76636427338610fc9f046..9278f28dada6da335932a76669810daaf8d0abf2 100644
--- a/kayobe/cli/commands.py
+++ b/kayobe/cli/commands.py
@@ -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."""
diff --git a/kayobe/tests/unit/cli/test_commands.py b/kayobe/tests/unit/cli/test_commands.py
index 07748a7e9eec3f2a1add719f96b0c680f55096d5..28cd7cbcc01607be65184fd416097e09ec98edfd 100644
--- a/kayobe/tests/unit/cli/test_commands.py
+++ b/kayobe/tests/unit/cli/test_commands.py
@@ -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):
diff --git a/releasenotes/notes/add-ironic-node-serial-console-commands-75f1255d62e05c87.yaml b/releasenotes/notes/add-ironic-node-serial-console-commands-75f1255d62e05c87.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..cddb07d1c6b9a4d50ca00e545b0a15ee6e4a0fba
--- /dev/null
+++ b/releasenotes/notes/add-ironic-node-serial-console-commands-75f1255d62e05c87.yaml
@@ -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.
diff --git a/setup.cfg b/setup.cfg
index d9a4b670e827a7f027e3c8afead875b16c63a550..a0b4773cb5e12cea3b3490d069bd0f51680da56a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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