Add devstack base job for zuul v3

This should be managed in the devstack repo, since it's a base job to
run devstack.

Change-Id: Iffe54fbccbccd68db08f79a1b51dd7f76dbff408
Depends-On: Ie2119f24360d56690ffd772b95a9ea6b98dd4a39
diff --git a/roles/configure-swap/README.rst b/roles/configure-swap/README.rst
new file mode 100644
index 0000000..eaba5cf
--- /dev/null
+++ b/roles/configure-swap/README.rst
@@ -0,0 +1,11 @@
+Configure a swap partition
+
+Creates a swap partition on the ephemeral block device (the rest of which
+will be mounted on /opt).
+
+**Role Variables**
+
+.. zuul:rolevar:: configure_swap_size
+   :default: 8192
+
+   The size of the swap partition, in MiB.
diff --git a/roles/configure-swap/defaults/main.yaml b/roles/configure-swap/defaults/main.yaml
new file mode 100644
index 0000000..4d62232
--- /dev/null
+++ b/roles/configure-swap/defaults/main.yaml
@@ -0,0 +1 @@
+configure_swap_size: 8192
diff --git a/roles/configure-swap/tasks/ephemeral.yaml b/roles/configure-swap/tasks/ephemeral.yaml
new file mode 100644
index 0000000..c2316ea
--- /dev/null
+++ b/roles/configure-swap/tasks/ephemeral.yaml
@@ -0,0 +1,110 @@
+# Configure attached ephemeral devices for storage and swap
+
+- assert:
+    that:
+      - "ephemeral_device is defined"
+
+- name: Set partition names
+  set_fact:
+    swap_partition: "{{ ephemeral_device}}1"
+    opt_partition: "{{ ephemeral_device}}2"
+
+- name: Ensure ephemeral device is unmounted
+  become: yes
+  mount:
+    name: "{{ ephemeral_device }}"
+    state: unmounted
+
+- name: Get existing partitions
+  become: yes
+  parted:
+    device: "{{ ephemeral_device }}"
+    unit: MiB
+  register: ephemeral_partitions
+
+- name: Remove any existing partitions
+  become: yes
+  parted:
+    device: "{{ ephemeral_device }}"
+    number: "{{ item.num }}"
+    state: absent
+  with_items:
+    - "{{ ephemeral_partitions.partitions }}"
+
+- name: Create new disk label
+  become: yes
+  parted:
+    label: msdos
+    device: "{{ ephemeral_device }}"
+
+- name: Create swap partition
+  become: yes
+  parted:
+    device: "{{ ephemeral_device }}"
+    number: 1
+    state: present
+    part_start: '0%'
+    part_end: "{{ configure_swap_size }}MiB"
+
+- name: Create opt partition
+  become: yes
+  parted:
+    device: "{{ ephemeral_device }}"
+    number: 2
+    state: present
+    part_start: "{{ configure_swap_size }}MiB"
+    part_end: "100%"
+
+- name: Make swap on partition
+  become: yes
+  command: "mkswap {{ swap_partition }}"
+
+- name: Write swap to fstab
+  become: yes
+  mount:
+    path: none
+    src: "{{ swap_partition }}"
+    fstype: swap
+    opts: sw
+    passno: 0
+    dump: 0
+    state: present
+
+# XXX: does "parted" plugin ensure the partition is available
+# before moving on?  No udev settles here ...
+
+- name: Add all swap
+  become: yes
+  command: swapon -a
+
+- name: Create /opt filesystem
+  become: yes
+  filesystem:
+    fstype: ext4
+    dev: "{{ opt_partition }}"
+
+# Rackspace at least does not have enough room for two devstack
+# installs on the primary partition.  We copy in the existing /opt to
+# the new partition on the ephemeral device, and then overmount /opt
+# to there for the test runs.
+#
+# NOTE(ianw): the existing "mount" touches fstab.  There is currently (Sep2017)
+# work in [1] to split mount & fstab into separate parts, but for now we bundle
+# it into an atomic shell command
+# [1] https://github.com/ansible/ansible/pull/27174
+- name: Copy old /opt
+  become: yes
+  shell: |
+    mount {{ opt_partition }} /mnt
+    find /opt/ -mindepth 1 -maxdepth 1 -exec mv {} /mnt/ \;
+    umount /mnt
+
+# This overmounts any existing /opt
+- name: Add opt to fstab and mount
+  become: yes
+  mount:
+    path: /opt
+    src: "{{ opt_partition }}"
+    fstype: ext4
+    opts: noatime
+    state: mounted
diff --git a/roles/configure-swap/tasks/main.yaml b/roles/configure-swap/tasks/main.yaml
new file mode 100644
index 0000000..8960c72
--- /dev/null
+++ b/roles/configure-swap/tasks/main.yaml
@@ -0,0 +1,63 @@
+# On RAX hosts, we have a small root partition and a large,
+# unallocated ephemeral device attached at /dev/xvde
+- name: Set ephemeral device if /dev/xvde exists
+  when: ansible_devices["xvde"] is defined
+  set_fact:
+    ephemeral_device: "/dev/xvde"
+
+# On other providers, we have a device called "ephemeral0".
+#
+# NOTE(ianw): Once [1] is in our ansible (2.4 era?), we can figure
+# this out more directly by walking the device labels in the facts
+#
+# [1] https://github.com/ansible/ansible/commit/d46dd99f47c0ee5081d15bc5b741e9096d8bfd3e
+- name: Set ephemeral device by label
+  when: ephemeral_device is undefined
+  block:
+    - name: Get ephemeral0 device node
+      command: /sbin/blkid -L ephemeral0
+      register: ephemeral0
+      # If this doesn't exist, returns !0
+      ignore_errors: yes
+      changed_when: False
+
+    - name: Set ephemeral device if LABEL exists
+      when: "ephemeral0.rc == 0"
+      set_fact:
+        ephemeral_device: "{{ ephemeral0.stdout }}"
+
+# If we have ephemeral storage and we don't appear to have setup swap,
+# we will create a swap and move /opt to a large data partition there.
+- include: ephemeral.yaml
+  static: no
+  when:
+    - ephemeral_device is defined
+    - ansible_memory_mb['swap']['total'] | int + 10 <= configure_swap_size
+
+# If no ephemeral device and no swap, then we will setup some swap
+# space on the root device to ensure all hosts a consistent memory
+# environment.
+- include: root.yaml
+  static: no
+  when:
+    - ephemeral_device is undefined
+    - ansible_memory_mb['swap']['total'] | int + 10 <= configure_swap_size
+
+# ensure a standard level of swappiness.  Some platforms
+# (rax+centos7) come with swappiness of 0 (presumably because the
+# vm doesn't come with swap setup ... but we just did that above),
+# which depending on the kernel version can lead to the OOM killer
+# kicking in on some processes despite swap being available;
+# particularly things like mysql which have very high ratio of
+# anonymous-memory to file-backed mappings.
+#
+# This sets swappiness low; we really don't want to be relying on
+# cloud I/O based swap during our runs if we can help it
+- name: Set swappiness
+  become: yes
+  sysctl:
+    name: vm.swappiness
+    value: 30
+    state: present
+
+- debug:  var=ephemeral_device
diff --git a/roles/configure-swap/tasks/root.yaml b/roles/configure-swap/tasks/root.yaml
new file mode 100644
index 0000000..f22b537
--- /dev/null
+++ b/roles/configure-swap/tasks/root.yaml
@@ -0,0 +1,63 @@
+# If no ephemeral devices are available, use root filesystem
+
+- name: Calculate required swap
+  set_fact:
+    swap_required: "{{ configure_swap_size - ansible_memory_mb['swap']['total'] | int }}"
+
+- block:
+    - name: Get root filesystem
+      shell: df --output='fstype' /root | tail -1
+      register: root_fs
+
+    - name: Save root filesystem
+      set_fact:
+        root_filesystem: "{{ root_fs.stdout }}"
+
+    - debug: var=root_filesystem
+
+# Note, we don't use a sparse device to avoid wedging when disk space
+# and memory are both unavailable.
+
+# Cannot fallocate on filesystems like XFS, so use slower dd
+- name: Create swap backing file for non-EXT fs
+  when: '"ext" not in root_filesystem'
+  become: yes
+  command: dd if=/dev/zero of=/root/swapfile bs=1M count={{ swap_required }}
+  args:
+    creates: /root/swapfile
+
+- name: Create sparse swap backing file for EXT fs
+  when: '"ext" in root_filesystem'
+  become: yes
+  command: fallocate -l {{ swap_required }}M /root/swapfile
+  args:
+    creates: /root/swapfile
+
+- name: Ensure swapfile perms
+  become: yes
+  file:
+    path: /root/swapfile
+    owner: root
+    group: root
+    mode: 0600
+
+- name: Make swapfile
+  become: yes
+  command: mkswap /root/swapfile
+
+- name: Write swap to fstab
+  become: yes
+  mount:
+    path: none
+    src: /root/swapfile
+    fstype: swap
+    opts: sw
+    passno: 0
+    dump: 0
+    state: present
+
+- name: Add all swap
+  become: yes
+  command: swapon -a
+
+- debug: var=swap_required
diff --git a/roles/export-devstack-journal/README.rst b/roles/export-devstack-journal/README.rst
new file mode 100644
index 0000000..5f00592
--- /dev/null
+++ b/roles/export-devstack-journal/README.rst
@@ -0,0 +1,15 @@
+Export journal files from devstack services
+
+Export the systemd journal for every devstack service in native
+journal format as well as text.  Also, export a syslog-style file with
+kernal and sudo messages.
+
+Writes the output to the ``logs/`` subdirectory of
+``devstack_base_dir``.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/export-devstack-journal/defaults/main.yaml b/roles/export-devstack-journal/defaults/main.yaml
new file mode 100644
index 0000000..fea05c8
--- /dev/null
+++ b/roles/export-devstack-journal/defaults/main.yaml
@@ -0,0 +1 @@
+devstack_base_dir: /opt/stack
diff --git a/roles/export-devstack-journal/tasks/main.yaml b/roles/export-devstack-journal/tasks/main.yaml
new file mode 100644
index 0000000..b9af02a
--- /dev/null
+++ b/roles/export-devstack-journal/tasks/main.yaml
@@ -0,0 +1,29 @@
+# TODO: convert this to ansible
+- name: Export journal files
+  become: true
+  shell:
+    cmd: |
+      u=""
+      name=""
+      for u in `systemctl list-unit-files | grep devstack | awk '{print $1}'`; do
+        name=$(echo $u | sed 's/devstack@/screen-/' | sed 's/\.service//')
+        journalctl -o short-precise --unit $u | tee {{ devstack_base_dir }}/logs/$name.txt > /dev/null
+      done
+
+      # Export the journal in export format to make it downloadable
+      # for later searching. It can then be rewritten to a journal native
+      # format locally using systemd-journal-remote. This makes a class of
+      # debugging much easier. We don't do the native conversion here as
+      # some distros do not package that tooling.
+      journalctl -u 'devstack@*' -o export | \
+          xz --threads=0 - > {{ devstack_base_dir }}/logs/devstack.journal.xz
+
+      # The journal contains everything running under systemd, we'll
+      # build an old school version of the syslog with just the
+      # kernel and sudo messages.
+      journalctl \
+          -t kernel \
+          -t sudo \
+          --no-pager \
+          --since="$(cat {{ devstack_base_dir }}/log-start-timestamp.txt)" \
+        | tee {{ devstack_base_dir }}/logs/syslog.txt > /dev/null
diff --git a/roles/fetch-devstack-log-dir/README.rst b/roles/fetch-devstack-log-dir/README.rst
new file mode 100644
index 0000000..360a2e3
--- /dev/null
+++ b/roles/fetch-devstack-log-dir/README.rst
@@ -0,0 +1,10 @@
+Fetch content from the devstack log directory
+
+Copy logs from every host back to the zuul executor.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/fetch-devstack-log-dir/defaults/main.yaml b/roles/fetch-devstack-log-dir/defaults/main.yaml
new file mode 100644
index 0000000..fea05c8
--- /dev/null
+++ b/roles/fetch-devstack-log-dir/defaults/main.yaml
@@ -0,0 +1 @@
+devstack_base_dir: /opt/stack
diff --git a/roles/fetch-devstack-log-dir/tasks/main.yaml b/roles/fetch-devstack-log-dir/tasks/main.yaml
new file mode 100644
index 0000000..5a198b2
--- /dev/null
+++ b/roles/fetch-devstack-log-dir/tasks/main.yaml
@@ -0,0 +1,5 @@
+- name: Collect devstack logs
+  synchronize:
+    dest: "{{ zuul.executor.log_root }}/{{ inventory_hostname }}"
+    mode: pull
+    src: "{{ devstack_base_dir }}/logs"
diff --git a/roles/run-devstack/README.rst b/roles/run-devstack/README.rst
new file mode 100644
index 0000000..d77eb15
--- /dev/null
+++ b/roles/run-devstack/README.rst
@@ -0,0 +1,8 @@
+Run devstack
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/run-devstack/defaults/main.yaml b/roles/run-devstack/defaults/main.yaml
new file mode 100644
index 0000000..fea05c8
--- /dev/null
+++ b/roles/run-devstack/defaults/main.yaml
@@ -0,0 +1 @@
+devstack_base_dir: /opt/stack
diff --git a/roles/run-devstack/tasks/main.yaml b/roles/run-devstack/tasks/main.yaml
new file mode 100644
index 0000000..bafebaf
--- /dev/null
+++ b/roles/run-devstack/tasks/main.yaml
@@ -0,0 +1,6 @@
+- name: Run devstack
+  command: ./stack.sh
+  args:
+    chdir: "{{devstack_base_dir}}/devstack"
+  become: true
+  become_user: stack
diff --git a/roles/setup-devstack-cache/README.rst b/roles/setup-devstack-cache/README.rst
new file mode 100644
index 0000000..b8938c3
--- /dev/null
+++ b/roles/setup-devstack-cache/README.rst
@@ -0,0 +1,15 @@
+Set up the devstack cache directory
+
+If the node has a cache of devstack image files, copy it into place.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
+
+.. zuul:rolevar:: devstack_cache_dir
+   :default: /opt/cache
+
+   The directory with the cached files.
diff --git a/roles/setup-devstack-cache/defaults/main.yaml b/roles/setup-devstack-cache/defaults/main.yaml
new file mode 100644
index 0000000..c56720b
--- /dev/null
+++ b/roles/setup-devstack-cache/defaults/main.yaml
@@ -0,0 +1,2 @@
+devstack_base_dir: /opt/stack
+devstack_cache_dir: /opt/cache
diff --git a/roles/setup-devstack-cache/tasks/main.yaml b/roles/setup-devstack-cache/tasks/main.yaml
new file mode 100644
index 0000000..84f33f0
--- /dev/null
+++ b/roles/setup-devstack-cache/tasks/main.yaml
@@ -0,0 +1,14 @@
+- name: Copy cached devstack files
+  # This uses hard links to avoid using extra space.
+  command: "find {{ devstack_cache_dir }}/files -mindepth 1 -maxdepth 1 -exec cp -l {} {{ devstack_base_dir }}/devstack/files/ ;"
+  become: true
+
+- name: Set ownership of cached files
+  file:
+    path: '{{ devstack_base_dir }}/devstack/files'
+    state: directory
+    recurse: true
+    owner: stack
+    group: stack
+    mode: a+r
+  become: yes
diff --git a/roles/setup-devstack-log-dir/README.rst b/roles/setup-devstack-log-dir/README.rst
new file mode 100644
index 0000000..9d8dba3
--- /dev/null
+++ b/roles/setup-devstack-log-dir/README.rst
@@ -0,0 +1,11 @@
+Set up the devstack log directory
+
+Create a log directory on the ephemeral disk partition to save space
+on the root device.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/setup-devstack-log-dir/defaults/main.yaml b/roles/setup-devstack-log-dir/defaults/main.yaml
new file mode 100644
index 0000000..fea05c8
--- /dev/null
+++ b/roles/setup-devstack-log-dir/defaults/main.yaml
@@ -0,0 +1 @@
+devstack_base_dir: /opt/stack
diff --git a/roles/setup-devstack-log-dir/tasks/main.yaml b/roles/setup-devstack-log-dir/tasks/main.yaml
new file mode 100644
index 0000000..b9f38df
--- /dev/null
+++ b/roles/setup-devstack-log-dir/tasks/main.yaml
@@ -0,0 +1,5 @@
+- name: Create logs directory
+  file:
+    path: '{{ devstack_base_dir }}/logs'
+    state: directory
+  become: yes
diff --git a/roles/setup-devstack-source-dirs/README.rst b/roles/setup-devstack-source-dirs/README.rst
new file mode 100644
index 0000000..4ebf839
--- /dev/null
+++ b/roles/setup-devstack-source-dirs/README.rst
@@ -0,0 +1,11 @@
+Set up the devstack source directories
+
+Ensure that the base directory exists, and then move the source repos
+into it.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/setup-devstack-source-dirs/defaults/main.yaml b/roles/setup-devstack-source-dirs/defaults/main.yaml
new file mode 100644
index 0000000..fea05c8
--- /dev/null
+++ b/roles/setup-devstack-source-dirs/defaults/main.yaml
@@ -0,0 +1 @@
+devstack_base_dir: /opt/stack
diff --git a/roles/setup-devstack-source-dirs/tasks/main.yaml b/roles/setup-devstack-source-dirs/tasks/main.yaml
new file mode 100644
index 0000000..e6bbae2
--- /dev/null
+++ b/roles/setup-devstack-source-dirs/tasks/main.yaml
@@ -0,0 +1,22 @@
+- name: Find all source repos used by this job
+  find:
+    paths:
+      - src/git.openstack.org/openstack
+      - src/git.openstack.org/openstack-dev
+      - src/git.openstack.org/openstack-infra
+    file_type: directory
+  register: found_repos
+
+- name: Copy Zuul repos into devstack working directory
+  command: rsync -a {{ item.path }} {{ devstack_base_dir }}
+  with_items: '{{ found_repos.files }}'
+  become: yes
+
+- name: Set ownership of repos
+  file:
+    path: '{{ devstack_base_dir }}'
+    state: directory
+    recurse: true
+    owner: stack
+    group: stack
+  become: yes
diff --git a/roles/setup-stack-user/README.rst b/roles/setup-stack-user/README.rst
new file mode 100644
index 0000000..80c4d39
--- /dev/null
+++ b/roles/setup-stack-user/README.rst
@@ -0,0 +1,16 @@
+Set up the `stack` user
+
+Create the stack user, set up its home directory, and allow it to
+sudo.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
+
+.. zuul:rolevar:: devstack_stack_home_dir
+   :default: {{ devstack_base_dir }}
+
+   The home directory for the stack user.
diff --git a/roles/setup-stack-user/defaults/main.yaml b/roles/setup-stack-user/defaults/main.yaml
new file mode 100644
index 0000000..6d0be66
--- /dev/null
+++ b/roles/setup-stack-user/defaults/main.yaml
@@ -0,0 +1,2 @@
+devstack_base_dir: /opt/stack
+devstack_stack_home_dir: '{{ devstack_base_dir }}'
diff --git a/roles/setup-stack-user/files/50_stack_sh b/roles/setup-stack-user/files/50_stack_sh
new file mode 100644
index 0000000..4c6b46b
--- /dev/null
+++ b/roles/setup-stack-user/files/50_stack_sh
@@ -0,0 +1 @@
+stack ALL=(root) NOPASSWD:ALL
diff --git a/roles/setup-stack-user/tasks/main.yaml b/roles/setup-stack-user/tasks/main.yaml
new file mode 100644
index 0000000..8384515
--- /dev/null
+++ b/roles/setup-stack-user/tasks/main.yaml
@@ -0,0 +1,45 @@
+- name: Create stack group
+  group:
+    name: stack
+  become: yes
+
+# NOTE(andreaf) Create a user home_dir is not safe via
+# the user module since it will fail if the containing
+# folder does not exists. If the folder does exists and
+# it's empty, the skeleton is setup and ownership set.
+- name: Create the stack user home folder
+  file:
+    path: '{{ devstack_stack_home_dir }}'
+    state: directory
+  become: yes
+
+- name: Create stack user
+  user:
+    name: stack
+    shell: /bin/bash
+    home: '{{ devstack_stack_home_dir }}'
+    group: stack
+  become: yes
+
+- name: Set stack user home directory permissions
+  file:
+    path: '{{ devstack_stack_home_dir }}'
+    mode: 0755
+  become: yes
+
+- name: Copy 50_stack_sh file to /etc/sudoers.d
+  copy:
+    src: 50_stack_sh
+    dest: /etc/sudoers.d
+    mode: 0440
+    owner: root
+    group: root
+  become: yes
+
+- name: Create new/.cache folder within BASE
+  file:
+    path: '{{ devstack_stack_home_dir }}/.cache'
+    state: directory
+    owner: stack
+    group: stack
+  become: yes
diff --git a/roles/setup-tempest-user/README.rst b/roles/setup-tempest-user/README.rst
new file mode 100644
index 0000000..bb29c50
--- /dev/null
+++ b/roles/setup-tempest-user/README.rst
@@ -0,0 +1,10 @@
+Set up the `tempest` user
+
+Create the tempest user and allow it to sudo.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/setup-tempest-user/files/51_tempest_sh b/roles/setup-tempest-user/files/51_tempest_sh
new file mode 100644
index 0000000..f88ff9f
--- /dev/null
+++ b/roles/setup-tempest-user/files/51_tempest_sh
@@ -0,0 +1,3 @@
+tempest ALL=(root) NOPASSWD:/sbin/ip
+tempest ALL=(root) NOPASSWD:/sbin/iptables
+tempest ALL=(root) NOPASSWD:/usr/bin/ovsdb-client
diff --git a/roles/setup-tempest-user/tasks/main.yaml b/roles/setup-tempest-user/tasks/main.yaml
new file mode 100644
index 0000000..892eaf6
--- /dev/null
+++ b/roles/setup-tempest-user/tasks/main.yaml
@@ -0,0 +1,20 @@
+- name: Create tempest group
+  group:
+    name: tempest
+  become: yes
+
+- name: Create tempest user
+  user:
+    name: tempest
+    shell: /bin/bash
+    group: tempest
+  become: yes
+
+- name: Copy 51_tempest_sh to /etc/sudoers.d
+  copy:
+    src: 51_tempest_sh
+    dest: /etc/sudoers.d
+    owner: root
+    group: root
+    mode: 0440
+  become: yes
diff --git a/roles/start-fresh-logging/README.rst b/roles/start-fresh-logging/README.rst
new file mode 100644
index 0000000..11b029e
--- /dev/null
+++ b/roles/start-fresh-logging/README.rst
@@ -0,0 +1,11 @@
+Restart logging on all hosts
+
+Restart syslog so that the system logs only include output from the
+job.
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
diff --git a/roles/start-fresh-logging/defaults/main.yaml b/roles/start-fresh-logging/defaults/main.yaml
new file mode 100644
index 0000000..fea05c8
--- /dev/null
+++ b/roles/start-fresh-logging/defaults/main.yaml
@@ -0,0 +1 @@
+devstack_base_dir: /opt/stack
diff --git a/roles/start-fresh-logging/tasks/main.yaml b/roles/start-fresh-logging/tasks/main.yaml
new file mode 100644
index 0000000..6c7ba66
--- /dev/null
+++ b/roles/start-fresh-logging/tasks/main.yaml
@@ -0,0 +1,56 @@
+- name: Check for /bin/journalctl file
+  command: which journalctl
+  changed_when: False
+  failed_when: False
+  register: which_out
+
+- block:
+    - name: Get current date
+      command: date +"%Y-%m-%d %H:%M:%S"
+      register: date_out
+
+    - name: Copy current date to log-start-timestamp.txt
+      copy:
+        dest: "{{ devstack_base_dir }}/log-start-timestamp.txt"
+        content: "{{ date_out.stdout }}"
+  when: which_out.rc == 0
+  become: yes
+
+- block:
+    - name: Stop rsyslog
+      service: name=rsyslog state=stopped
+
+    - name: Save syslog file prior to devstack run
+      command: mv /var/log/syslog /var/log/syslog-pre-devstack
+
+    - name: Save kern.log file prior to devstack run
+      command: mv /var/log/kern.log /var/log/kern_log-pre-devstack
+
+    - name: Recreate syslog file
+      file: name=/var/log/syslog state=touch
+
+    - name: Recreate syslog file owner and group
+      command: chown /var/log/syslog --ref /var/log/syslog-pre-devstack
+
+    - name: Recreate syslog file permissions
+      command: chmod /var/log/syslog --ref /var/log/syslog-pre-devstack
+
+    - name: Add read permissions to all on syslog file
+      file: name=/var/log/syslog mode=a+r
+
+    - name: Recreate kern.log file
+      file: name=/var/log/kern.log state=touch
+
+    - name: Recreate kern.log file owner and group
+      command: chown /var/log/kern.log --ref /var/log/kern_log-pre-devstack
+
+    - name: Recreate kern.log file permissions
+      command: chmod /var/log/kern.log --ref /var/log/kern_log-pre-devstack
+
+    - name: Add read permissions to all on kern.log file
+      file: name=/var/log/kern.log mode=a+r
+
+    - name: Start rsyslog
+      service: name=rsyslog state=started
+  when: which_out.rc == 1
+  become: yes
diff --git a/roles/write-devstack-local-conf/README.rst b/roles/write-devstack-local-conf/README.rst
new file mode 100644
index 0000000..e30dfa1
--- /dev/null
+++ b/roles/write-devstack-local-conf/README.rst
@@ -0,0 +1,63 @@
+Write the local.conf file for use by devstack
+
+**Role Variables**
+
+.. zuul:rolevar:: devstack_base_dir
+   :default: /opt/stack
+
+   The devstack base directory.
+
+.. zuul:rolevar:: devstack_local_conf_path
+   :default: {{ devstack_base_dir }}/devstack/local.conf
+
+   The path of the local.conf file.
+
+.. zuul:rolevar:: devstack_localrc
+   :type: dict
+
+   A dictionary of variables that should be written to the localrc
+   section of local.conf.  The values (which are strings) may contain
+   bash shell variables, and will be ordered so that variables used by
+   later entries appear first.
+
+.. zuul:rolevar:: devstack_local_conf
+   :type: dict
+
+   A complex argument consisting of nested dictionaries which combine
+   to form the meta-sections of the local_conf file.  The top level is
+   a dictionary of phases, followed by dictionaries of filenames, then
+   sections, which finally contain key-value pairs for the INI file
+   entries in those sections.
+
+   The keys in this dictionary are the devstack phases.
+
+   .. zuul:rolevar:: [phase]
+      :type: dict
+
+      The keys in this dictionary are the filenames for this phase.
+
+      .. zuul:rolevar:: [filename]
+         :type: dict
+
+         The keys in this dictionary are the INI sections in this file.
+
+         .. zuul:rolevar:: [section]
+            :type: dict
+
+            This is a dictionary of key-value pairs which comprise
+            this section of the INI file.
+
+.. zuul:rolevar:: devstack_services
+   :type: dict
+
+   A dictionary mapping service names to boolean values.  If the
+   boolean value is ``false``, a ``disable_service`` line will be
+   emitted for the service name.  If it is ``true``, then
+   ``enable_service`` will be emitted.  All other values are ignored.
+
+.. zuul:rolevar:: devstack_plugins
+   :type: dict
+
+   A dictionary mapping a plugin name to a git repo location.  If the
+   location is a non-empty string, then an ``enable_plugin`` line will
+   be emmitted for the plugin name.
diff --git a/roles/write-devstack-local-conf/defaults/main.yaml b/roles/write-devstack-local-conf/defaults/main.yaml
new file mode 100644
index 0000000..491fa0f
--- /dev/null
+++ b/roles/write-devstack-local-conf/defaults/main.yaml
@@ -0,0 +1,2 @@
+devstack_base_dir: /opt/stack
+devstack_local_conf_path: "{{ devstack_base_dir }}/devstack/local.conf"
diff --git a/roles/write-devstack-local-conf/library/devstack_local_conf.py b/roles/write-devstack-local-conf/library/devstack_local_conf.py
new file mode 100644
index 0000000..4134beb
--- /dev/null
+++ b/roles/write-devstack-local-conf/library/devstack_local_conf.py
@@ -0,0 +1,185 @@
+# Copyright (C) 2017 Red Hat, Inc.
+#
+# 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.
+
+import re
+
+
+class VarGraph(object):
+    # This is based on the JobGraph from Zuul.
+
+    def __init__(self, vars):
+        self.vars = {}
+        self._varnames = set()
+        self._dependencies = {}  # dependent_var_name -> set(parent_var_names)
+        for k, v in vars.items():
+            self._varnames.add(k)
+        for k, v in vars.items():
+            self._addVar(k, str(v))
+
+    bash_var_re = re.compile(r'\$\{?(\w+)')
+    def getDependencies(self, value):
+        return self.bash_var_re.findall(value)
+
+    def _addVar(self, key, value):
+        if key in self.vars:
+            raise Exception("Variable {} already added".format(key))
+        self.vars[key] = value
+        # Append the dependency information
+        self._dependencies.setdefault(key, set())
+        try:
+            for dependency in self.getDependencies(value):
+                if dependency == key:
+                    # A variable is allowed to reference itself; no
+                    # dependency link needed in that case.
+                    continue
+                if dependency not in self._varnames:
+                    # It's not necessary to create a link for an
+                    # external variable.
+                    continue
+                # Make sure a circular dependency is never created
+                ancestor_vars = self._getParentVarNamesRecursively(
+                    dependency, soft=True)
+                ancestor_vars.add(dependency)
+                if any((key == anc_var) for anc_var in ancestor_vars):
+                    raise Exception("Dependency cycle detected in var {}".
+                                    format(key))
+                self._dependencies[key].add(dependency)
+        except Exception:
+            del self.vars[key]
+            del self._dependencies[key]
+            raise
+
+    def getVars(self):
+        ret = []
+        keys = sorted(self.vars.keys())
+        seen = set()
+        for key in keys:
+            dependencies = self.getDependentVarsRecursively(key)
+            for var in dependencies + [key]:
+                if var not in seen:
+                    ret.append((var, self.vars[var]))
+                    seen.add(var)
+        return ret
+
+    def getDependentVarsRecursively(self, parent_var):
+        dependent_vars = []
+
+        current_dependent_vars = self._dependencies[parent_var]
+        for current_var in current_dependent_vars:
+            if current_var not in dependent_vars:
+                dependent_vars.append(current_var)
+            for dep in self.getDependentVarsRecursively(current_var):
+                if dep not in dependent_vars:
+                    dependent_vars.append(dep)
+        return dependent_vars
+
+    def _getParentVarNamesRecursively(self, dependent_var, soft=False):
+        all_parent_vars = set()
+        vars_to_iterate = set([dependent_var])
+        while len(vars_to_iterate) > 0:
+            current_var = vars_to_iterate.pop()
+            current_parent_vars = self._dependencies.get(current_var)
+            if current_parent_vars is None:
+                if soft:
+                    current_parent_vars = set()
+                else:
+                    raise Exception("Dependent var {} not found: ".format(
+                                    dependent_var))
+            new_parent_vars = current_parent_vars - all_parent_vars
+            vars_to_iterate |= new_parent_vars
+            all_parent_vars |= new_parent_vars
+        return all_parent_vars
+
+
+class LocalConf(object):
+
+    def __init__(self, localrc, localconf, services, plugins):
+        self.localrc = []
+        self.meta_sections = {}
+        if plugins:
+            self.handle_plugins(plugins)
+        if services:
+            self.handle_services(services)
+        if localrc:
+            self.handle_localrc(localrc)
+        if localconf:
+            self.handle_localconf(localconf)
+
+    def handle_plugins(self, plugins):
+        for k, v in plugins.items():
+            if v:
+                self.localrc.append('enable_plugin {} {}'.format(k, v))
+
+    def handle_services(self, services):
+        for k, v in services.items():
+            if v is False:
+                self.localrc.append('disable_service {}'.format(k))
+            elif v is True:
+                self.localrc.append('enable_service {}'.format(k))
+
+    def handle_localrc(self, localrc):
+        vg = VarGraph(localrc)
+        for k, v in vg.getVars():
+            self.localrc.append('{}={}'.format(k, v))
+
+    def handle_localconf(self, localconf):
+        for phase, phase_data in localconf.items():
+            for fn, fn_data in phase_data.items():
+                ms_name = '[[{}|{}]]'.format(phase, fn)
+                ms_data = []
+                for section, section_data in fn_data.items():
+                    ms_data.append('[{}]'.format(section))
+                    for k, v in section_data.items():
+                        ms_data.append('{} = {}'.format(k, v))
+                    ms_data.append('')
+                self.meta_sections[ms_name] = ms_data
+
+    def write(self, path):
+        with open(path, 'w') as f:
+            f.write('[[local|localrc]]\n')
+            f.write('\n'.join(self.localrc))
+            f.write('\n\n')
+            for section, lines in self.meta_sections.items():
+                f.write('{}\n'.format(section))
+                f.write('\n'.join(lines))
+
+
+def main():
+    module = AnsibleModule(
+        argument_spec=dict(
+            plugins=dict(type='dict'),
+            services=dict(type='dict'),
+            localrc=dict(type='dict'),
+            local_conf=dict(type='dict'),
+            path=dict(type='str'),
+        )
+    )
+
+    p = module.params
+    lc = LocalConf(p.get('localrc'),
+                   p.get('local_conf'),
+                   p.get('services'),
+                   p.get('plugins'))
+    lc.write(p['path'])
+
+    module.exit_json()
+
+
+from ansible.module_utils.basic import *  # noqa
+from ansible.module_utils.basic import AnsibleModule
+
+if __name__ == '__main__':
+    main()
diff --git a/roles/write-devstack-local-conf/tasks/main.yaml b/roles/write-devstack-local-conf/tasks/main.yaml
new file mode 100644
index 0000000..1d67616
--- /dev/null
+++ b/roles/write-devstack-local-conf/tasks/main.yaml
@@ -0,0 +1,9 @@
+- name: Write a job-specific local_conf file
+  become: true
+  become_user: stack
+  devstack_local_conf:
+    path: "{{ devstack_local_conf_path }}"
+    plugins: "{{ devstack_plugins|default(omit) }}"
+    services: "{{ devstack_services|default(omit) }}"
+    localrc: "{{ devstack_localrc|default(omit) }}"
+    local_conf: "{{ devstack_local_conf|default(omit) }}"