diff --git a/ansible/group_vars/all/inspector b/ansible/group_vars/all/inspector
index e5f72bbc5..95e1e3117 100644
--- a/ansible/group_vars/all/inspector
+++ b/ansible/group_vars/all/inspector
@@ -3,14 +3,13 @@
 # Ironic inspector PXE configuration.
 
 # List of extra kernel parameters for the inspector default PXE configuration.
-inspector_extra_kernel_options:
-  - "ipa-collect-lldp=1"
+inspector_extra_kernel_options: "{{ ipa_kernel_options_collect_lldp }}"
 
 # URL of Ironic Python Agent (IPA) kernel image.
-inspector_ipa_kernel_upstream_url: "https://tarballs.openstack.org/ironic-python-agent/coreos/files/coreos_production_pxe-stable-ocata.vmlinuz"
+inspector_ipa_kernel_upstream_url: "{{ ipa_images_kernel_url }}"
 
 # URL of Ironic Python Agent (IPA) ramdisk image.
-inspector_ipa_ramdisk_upstream_url: "https://tarballs.openstack.org/ironic-python-agent/coreos/files/coreos_production_pxe_image-oem-stable-ocata.cpio.gz"
+inspector_ipa_ramdisk_upstream_url: "{{ ipa_images_ramdisk_url }}"
 
 ###############################################################################
 # Ironic inspector processing configuration.
diff --git a/ansible/group_vars/all/ipa b/ansible/group_vars/all/ipa
new file mode 100644
index 000000000..bf8cda881
--- /dev/null
+++ b/ansible/group_vars/all/ipa
@@ -0,0 +1,24 @@
+---
+# Ironic Python Agent (IPA) configuration.
+
+###############################################################################
+# Ironic Python Agent (IPA) images configuration.
+
+# Name of Ironic deployment kernel image to register in Glance.
+ipa_images_kernel_name: "ipa.vmlinuz"
+
+# URL of Ironic deployment kernel image to download.
+ipa_images_kernel_url: "https://tarballs.openstack.org/ironic-python-agent/coreos/files/coreos_production_pxe-stable-ocata.vmlinuz"
+
+# Name of Ironic deployment ramdisk image to register in Glance.
+ipa_images_ramdisk_name: "ipa.initramfs"
+
+# URL of Ironic deployment ramdisk image to download.
+ipa_images_ramdisk_url: "https://tarballs.openstack.org/ironic-python-agent/coreos/files/coreos_production_pxe_image-oem-stable-ocata.cpio.gz"
+
+###############################################################################
+# Ironic Python Agent (IPA) deployment configuration.
+
+# List of extra kernel parameters for the inspector default PXE configuration.
+ipa_kernel_options_collect_lldp:
+  - "ipa-collect-lldp=1"
diff --git a/ansible/group_vars/all/openstack b/ansible/group_vars/all/openstack
new file mode 100644
index 000000000..f40c3bbad
--- /dev/null
+++ b/ansible/group_vars/all/openstack
@@ -0,0 +1,31 @@
+---
+###############################################################################
+# OpenStack authentication configuration.
+
+# Overcloud authentication type. This should be a string compatible with the
+# 'auth_type' argument of most 'os_*' Ansible modules.
+openstack_auth_type: "password"
+
+# Overcloud authentication parameters. This should be a dict providing items
+# compatible with the 'auth' argument of most 'os_*' Ansible modules.
+# By default we pull these from the environment of the shell executing Ansible.
+openstack_auth:
+  project_domain_name: "{{ lookup('env', 'OS_PROJECT_DOMAIN_NAME') }}"
+  user_domain_name: "{{ lookup('env', 'OS_USER_DOMAIN_NAME') }}"
+  project_name: "{{ lookup('env', 'OS_PROJECT_NAME') }}"
+  username: "{{ lookup('env', 'OS_USERNAME') }}"
+  password: "{{ lookup('env', 'OS_PASSWORD') }}"
+  auth_url: "{{ lookup('env', 'OS_AUTH_URL') }}"
+
+# Overcloud authentication environment variables. These should be compatible
+# with the openstack client.
+# By default we pull these from the environment of the shell executing Ansible.
+openstack_auth_env:
+  OS_PROJECT_DOMAIN_NAME: "{{ lookup('env', 'OS_PROJECT_DOMAIN_NAME') }}"
+  OS_USER_DOMAIN_NAME: "{{ lookup('env', 'OS_USER_DOMAIN_NAME') }}"
+  OS_PROJECT_NAME: "{{ lookup('env', 'OS_PROJECT_NAME') }}"
+  OS_USERNAME: "{{ lookup('env', 'OS_USERNAME') }}"
+  OS_PASSWORD: "{{ lookup('env', 'OS_PASSWORD') }}"
+  OS_AUTH_URL: "{{ lookup('env', 'OS_AUTH_URL') }}"
+  OS_INTERFACE: "{{ lookup('env', 'OS_INTERFACE') }}"
+  OS_IDENTITY_API_VERSION: "{{ lookup('env', 'OS_IDENTITY_API_VERSION') }}"
diff --git a/ansible/ipa-images.yml b/ansible/ipa-images.yml
new file mode 100644
index 000000000..331f0fe0b
--- /dev/null
+++ b/ansible/ipa-images.yml
@@ -0,0 +1,9 @@
+---
+- name: Ensure Ironic Python Agent (IPA) images are downloaded and registered
+  hosts: controllers[0]
+  roles:
+    - role: ipa-images
+      ipa_images_venv: "{{ ansible_env['PWD'] }}/shade-venv"
+      ipa_images_openstack_auth_type: "{{ openstack_auth_type }}"
+      ipa_images_openstack_auth: "{{ openstack_auth }}"
+      ipa_images_cache_path: "{{ image_cache_path }}"
diff --git a/ansible/roles/ipa-images/defaults/main.yml b/ansible/roles/ipa-images/defaults/main.yml
new file mode 100644
index 000000000..2e27e1600
--- /dev/null
+++ b/ansible/roles/ipa-images/defaults/main.yml
@@ -0,0 +1,26 @@
+---
+# Path to virtualenv in which to install shade and its dependencies.
+ipa_images_venv:
+
+# Authentication type compatible with the 'os_image' Ansible module's
+# auth_type argument.
+ipa_images_openstack_auth_type:
+
+# Authentication parameters compatible with the 'os_image' Ansible module's
+# auth argument.
+ipa_images_openstack_auth: {}
+
+# Path to directory in which to store downloaded images.
+ipa_images_cache_path:
+
+# Name of Ironic deployment kernel image to register in Glance.
+ipa_images_kernel_name:
+
+# URL of Ironic deployment kernel image to download.
+ipa_images_kernel_url:
+
+# Name of Ironic deployment ramdisk image to register in Glance.
+ipa_images_ramdisk_name:
+
+# URL of Ironic deployment ramdisk image to download.
+ipa_images_ramdisk_url:
diff --git a/ansible/roles/ipa-images/meta/main.yml b/ansible/roles/ipa-images/meta/main.yml
new file mode 100644
index 000000000..d30e6bdf5
--- /dev/null
+++ b/ansible/roles/ipa-images/meta/main.yml
@@ -0,0 +1,4 @@
+---
+dependencies:
+  - role: shade
+    shade_venv: "{{ ipa_images_venv }}"
diff --git a/ansible/roles/ipa-images/tasks/main.yml b/ansible/roles/ipa-images/tasks/main.yml
new file mode 100644
index 000000000..e07a35c0e
--- /dev/null
+++ b/ansible/roles/ipa-images/tasks/main.yml
@@ -0,0 +1,45 @@
+---
+- name: Ensure image download directory exists
+  file:
+    path: "{{ ipa_images_cache_path }}"
+    state: directory
+    owner: "{{ ansible_user }}"
+    group: "{{ ansible_user }}"
+  become: True
+
+- name: Ensure Ironic Python Agent (IPA) images are downloaded
+  get_url:
+    url: "{{ item }}"
+    dest: "{{ ipa_images_cache_path }}"
+  with_items:
+    - "{{ ipa_images_kernel_url }}"
+    - "{{ ipa_images_ramdisk_url }}"
+
+# Note that setting this via a play or task variable seems to not
+# evaluate the Jinja variable reference, so we use set_fact.
+- name: Update the Ansible python interpreter fact to point to the virtualenv
+  set_fact:
+    ansible_python_interpreter: "{{ ipa_images_venv }}/bin/python"
+
+- name: Ensure Ironic Python Agent (IPA) images are registered with Glance
+  os_image:
+    auth_type: "{{ ipa_images_openstack_auth_type }}"
+    auth: "{{ ipa_images_openstack_auth }}"
+    name: "{{ item.name }}"
+    container_format: "{{ item.format }}"
+    disk_format: "{{ item.format }}"
+    state: present
+    filename: "{{ ipa_images_cache_path }}/{{ item.filename }}"
+  with_items:
+    - name: "{{ ipa_images_kernel_name }}"
+      filename: "{{ ipa_images_kernel_url | basename }}"
+      format: aki
+    - name: "{{ ipa_images_ramdisk_name }}"
+      filename: "{{ ipa_images_ramdisk_url | basename }}"
+      format: ari
+
+# This variable is unset before we set it, and it does not appear to be
+# possible to unset a variable in Ansible.
+- name: Set a fact to reset the Ansible python interpreter
+  set_fact:
+    ansible_python_interpreter: /usr/bin/python