Andrew Bogott has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/333987 )

Change subject: Horizon:  Add mitaka version of the puppetpanel.
......................................................................

Horizon:  Add mitaka version of the puppetpanel.

No doubt this will require some future tweaking.

Change-Id: I916270346bf4eed9bea1ff0dfe430379bb66267e
---
A modules/openstack/files/mitaka/horizon/puppettab/__init__.py
A modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/__init__.py
A modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/plustab.py
A modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/prefixpanel.py
A modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/urls.py
A modules/openstack/files/mitaka/horizon/puppettab/projectpanel.py
A modules/openstack/files/mitaka/horizon/puppettab/puppet_config.py
A modules/openstack/files/mitaka/horizon/puppettab/puppet_roles.py
A modules/openstack/files/mitaka/horizon/puppettab/puppet_tables.py
A 
modules/openstack/files/mitaka/horizon/puppettab/static/dashboard/puppet/puppet.scss
A modules/openstack/files/mitaka/horizon/puppettab/tab.py
A modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_apply.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_detail_puppet.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_edithiera.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_editotherclasses.html
A modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_hiera.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_other_classes.html
A modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_remove.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_removeprefix.html
A modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/apply.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/edithiera.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/editotherclasses.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/plus_tab.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/prefix_panel.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/project_panel.html
A modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/remove.html
A 
modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/removeprefix.html
A modules/openstack/files/mitaka/horizon/puppettab/urls.py
A modules/openstack/files/mitaka/horizon/puppettab/views.py
29 files changed, 1,426 insertions(+), 0 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/operations/puppet 
refs/changes/87/333987/1

diff --git a/modules/openstack/files/mitaka/horizon/puppettab/__init__.py 
b/modules/openstack/files/mitaka/horizon/puppettab/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/__init__.py
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/__init__.py 
b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/__init__.py
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/plustab.py 
b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/plustab.py
new file mode 100644
index 0000000..73b1776
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/plustab.py
@@ -0,0 +1,64 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    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 logging
+import re
+
+from django import template
+from django.template.loader import render_to_string
+from django.utils.translation import ugettext_lazy as _
+
+from wikimediapuppettab.puppet_config import puppet_config
+
+from horizon import exceptions
+from horizon import tabs
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+class PlusTab(tabs.Tab):
+    name = _("+")
+    slug = "puppetprefixplus"
+    template_name = "project/puppet/plus_tab.html"
+    prefix_name = False
+
+    def __init__(self, *args, **kwargs):
+        if 'tenant_id' in kwargs:
+            self.tenant_id = kwargs['tenant_id']
+            del kwargs['tenant_id']
+
+        super(PlusTab, self).__init__(*args, **kwargs)
+
+    def render(self):
+        context = template.RequestContext(self.request)
+        context['prefix_name'] = self.prefix_name
+        return render_to_string(self.get_template_name(self.request),
+                                self.data, context_instance=context)
+
+    def post(self, request, *args, **kwargs):
+
+        pattern = re.compile("^[A-Za-z][A-Za-z0-9_-]*$")
+        if not pattern.match(request.POST["prefix_name"]):
+            raise exceptions.BadRequest('Prefix must begin with a '
+                                        'letter and contain only letters, '
+                                        'numbers, _ or -.')
+
+        self.prefix_name = request.POST["prefix_name"]
+
+        # set an empty role list for this prefix, to force a
+        #  record creation on the back end.
+        config = puppet_config(self.prefix_name, self.tenant_id)
+        config.set_role_list([])
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/prefixpanel.py 
b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/prefixpanel.py
new file mode 100644
index 0000000..40b2fd6
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/prefixpanel.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+
+from collections import OrderedDict
+from django.utils.translation import ugettext_lazy as _
+import horizon
+import logging
+
+from horizon import tabs
+from horizon.tabs import TabGroup
+
+from wikimediapuppettab.tab import PuppetTab
+from wikimediapuppettab.prefixpanel.plustab import PlusTab
+from wikimediapuppettab.puppet_config import puppet_config
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+class PrefixPuppetPanel(horizon.Panel):
+    name = _("Prefix Puppet")
+    slug = "prefixpuppet"
+
+
+class PrefixTabs(tabs.TabGroup):
+    slug = "prefix_puppet"
+    sticky = False
+
+    def __init__(self, request, **kwargs):
+        super(TabGroup, self).__init__()
+
+        self.request = request
+        self.kwargs = kwargs
+        self._data = None
+        self.request = request
+
+        self.tenant_id = self.request.user.tenant_id
+        self._tabs = OrderedDict(self.get_dynamic_tab_list())
+        if self.sticky:
+            self.attrs['data-sticky-tabs'] = 'sticky'
+        if not self._set_active_tab():
+            self.tabs_not_available()
+
+    def get_dynamic_tab_list(self):
+        prefixlist = puppet_config.get_prefixes(self.tenant_id)
+        LOG.warning("prefixlist: %s" % prefixlist)
+
+        tab_instances = []
+        # One tab per prefix
+        for prefix in prefixlist:
+            # exclude anything with a '.' as those are instance names
+            #  handled on a different UI
+            if '.' in prefix:
+                continue
+            if prefix == '_':
+                continue
+            tab_instances.append(("puppet-%s" % prefix,
+                                  PuppetTab(self,
+                                            self.request,
+                                            prefix=prefix,
+                                            tenant_id=self.tenant_id)))
+
+        # + tab
+        tab_instances.append(('puppetprefixplus',
+                              PlusTab(self, self.request,
+                                      tenant_id=self.tenant_id)))
+        return tab_instances
+
+    def load_tab_data(self):
+        # This ensures that the tab list is updated without
+        #  having to rebuild the whole object.
+        self._tabs = OrderedDict(self.get_dynamic_tab_list())
+
+
+class IndexView(tabs.TabbedTableView):
+    tab_group_class = PrefixTabs
+    template_name = 'project/puppet/prefix_panel.html'
+    page_title = _("Prefix Puppet")
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/urls.py 
b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/urls.py
new file mode 100644
index 0000000..7786188
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/prefixpanel/urls.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from django.conf.urls import url, patterns
+
+from wikimediapuppettab.prefixpanel import prefixpanel
+from wikimediapuppettab import views
+
+urlpatterns = patterns(
+    '',
+    url(r'^$', prefixpanel.IndexView.as_view(), name='index'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        '(?P<roleid>[^/]+)/applypuppetrole$',
+        views.ApplyRoleView.as_view(), name='applypuppetrole'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        '(?P<roleid>[^/]+)/removepuppetrole$',
+        views.RemoveRoleView.as_view(), name='removepuppetrole'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        'edithiera$',
+        views.EditHieraView.as_view(), name='edithiera'),
+    url(r'^(?P<tenantid>[^/]+)/'
+        'newprefix$',
+        prefixpanel.IndexView.as_view(), name='newprefix'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        'removepuppetprefix$',
+        views.RemovePrefixView.as_view(), name='removepuppetprefix'),
+)
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/projectpanel.py 
b/modules/openstack/files/mitaka/horizon/puppettab/projectpanel.py
new file mode 100644
index 0000000..97cca49
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/projectpanel.py
@@ -0,0 +1,63 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    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 logging
+
+from django.utils.translation import ugettext_lazy as _
+import horizon
+
+from horizon import tabs
+
+import openstack_dashboard.dashboards.project.instances.tabs as instancetabs
+from wikimediapuppettab.tab import PuppetTab
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+class ProjectPuppetPanel(horizon.Panel):
+    name = _("Project Puppet")
+    slug = "puppet"
+
+    @staticmethod
+    def can_register():
+        # Hacky hook
+        #  While we're here, add tabs to the instance detail view as well
+        instancetabs.InstanceDetailTabs.tabs += (PuppetTab,)
+        return True
+
+
+class ProjectTabs(tabs.TabGroup):
+    slug = "project_puppet"
+    tabs = (PuppetTab, )
+    sticky = True
+
+
+class IndexView(tabs.TabbedTableView):
+    tab_group_class = ProjectTabs
+    template_name = 'project/puppet/project_panel.html'
+    page_title = _("Project Puppet")
+
+    def get_tabs(self, request, *args, **kwargs):
+        if self._tab_group is None:
+            tenant_id = self.request.user.tenant_id
+            caption = _("These puppet settings will affect all VMs"
+                        " in the %s project.") % tenant_id
+            self._tab_group = self.tab_group_class(request,
+                                                   prefix='_',
+                                                   caption=caption,
+                                                   tenant_id=tenant_id,
+                                                   **kwargs)
+        return self._tab_group
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/puppet_config.py 
b/modules/openstack/files/mitaka/horizon/puppettab/puppet_config.py
new file mode 100644
index 0000000..1565e93
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/puppet_config.py
@@ -0,0 +1,208 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    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 logging
+import requests
+import puppet_roles
+import yaml
+
+from django.conf import settings
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+# Get/set puppet config for a given instance.
+#
+# This class manages all communication with the home-made puppet REST backend
+class puppet_config():
+    def __init__(self, prefix, tenant_id):
+        self.prefix = prefix
+        self.tenant_id = tenant_id
+        self.apiurl = getattr(settings,
+                              "PUPPET_CONFIG_BACKEND",
+                              "http://labcontrol1001.wikimedia.org:8100/v1";
+                              )
+        self.refresh()
+
+    def refresh(self):
+        classesurl = "%s/%s/prefix/%s/roles" % (self.apiurl,
+                                                self.tenant_id,
+                                                self.prefix)
+        req = requests.get(classesurl, verify=False)
+        if req.status_code == 404:
+            self.roles = []
+        else:
+            req.raise_for_status()
+            self.roles = yaml.safe_load(req.text)['roles']
+
+        hieraurl = "%s/%s/prefix/%s/hiera" % (self.apiurl,
+                                              self.tenant_id,
+                                              self.prefix)
+        req = requests.get(hieraurl, verify=False)
+        if req.status_code == 404:
+            # Missing is the same as empty
+            self.hiera_raw = "{}"
+        else:
+            req.raise_for_status()
+            self.hiera_raw = yaml.safe_load(req.text)['hiera']
+
+        hiera_yaml = yaml.safe_load(self.hiera_raw)
+        if not hiera_yaml:
+            hiera_yaml = {}
+        self.role_dict = {}
+
+        self.allroles = puppet_roles.available_roles()
+        allrole_dict = {role.name: role for role in self.allroles}
+
+        # other_classes is a list of roles that weren't enumerated by the 
puppet API.
+        #  These could be roles from a locally hacked puppet repo, or roles 
that have been
+        #  deleted from the puppet repo but still appear in the instance 
config.
+        self.other_classes = []
+
+        # Find the hiera lines that assign params to applied and known roles.
+        #  these lines are removed from the hiera text and added as
+        #  structured data to the role records instead.
+        for role in list(self.roles):
+            if role in allrole_dict:
+                self.role_dict[role] = {}
+                for key in hiera_yaml.keys():
+                    if key.startswith(role):
+                        # (len(role)+2) is the length of the rolename plus the 
::,
+                        # getting us the raw param name
+                        argname = key[(len(role)+2):]
+                        if hiera_yaml[key]:
+                            self.role_dict[role][argname] = hiera_yaml[key]
+                            del hiera_yaml[key]
+                allrole_dict[role].mark_applied(self.role_dict[role])
+            elif role:
+                # This is an unknown role. Don't try to structure anything, 
just
+                #  add the rolename to the list and let hiera take care of the
+                #  params.
+                self.other_classes.append(role)
+                self.roles.remove(role)
+            else:
+                # Sometimes we get empty strings from crappy parsing
+                self.roles.remove(role)
+
+        self.other_classes_text = "\n".join(self.other_classes)
+
+        # Move the applied roles to the top for UI clarity
+        self.allroles.sort(key=lambda x: x.applied, reverse=True)
+
+        self.hiera = yaml.safe_dump(hiera_yaml, default_flow_style=False)
+
+    def remove_role(self, role):
+        if not self.roles:
+            LOG.error("Internal role list is empty, cannot remove")
+            # TODO throw an exception
+            return False
+
+        roles = self.roles
+
+        # Remove this role from our role list
+        roles.remove(role.name)
+
+        # Remove all related role args from hiera
+        hiera_yaml = yaml.safe_load(self.hiera_raw)
+        if hiera_yaml:
+            for key in hiera_yaml.keys():
+                if key.startswith("%s::" % role.name):
+                    del hiera_yaml[key]
+
+        self.set_role_list(roles)
+        self.set_hiera(hiera_yaml)
+
+    def apply_role(self, role, params):
+        if not self.roles:
+            # this is the first role, so build a fresh list
+            roles = [role.name]
+        else:
+            roles = list(self.roles)
+            if role.name not in roles:
+                roles.append(role.name)
+
+        # Translate the structured params and values
+        # into hiera yaml
+        hiera = yaml.safe_load(self.hiera_raw)
+        for param in params.keys():
+            fullparam = "%s::%s" % (role.name, param)
+            if fullparam in hiera:
+                if params[param]:
+                    hiera[fullparam] = params[param]
+                else:
+                    del hiera[fullparam]
+            else:
+                if params[param]:
+                    hiera[fullparam] = params[param]
+
+        self.set_role_list(roles)
+        self.set_hiera(hiera)
+
+    def set_roles(self, roles):
+        list_dump = yaml.safe_dump(roles, default_flow_style=False)
+        roleurl = "%s/%s/prefix/%s/roles" % (self.apiurl,
+                                             self.tenant_id,
+                                             self.prefix)
+        req = requests.post(roleurl,
+                            verify=False,
+                            data=list_dump,
+                            headers={'Content-Type': 'application/x-yaml'})
+        req.raise_for_status()
+        self.refresh()
+
+    def set_other_class_list(self, other_class_list):
+        self.set_roles(other_class_list + self.roles)
+
+    def set_role_list(self, role_list):
+        self.set_roles(role_list + self.other_classes)
+
+    def set_hiera(self, hiera_yaml):
+        if not hiera_yaml:
+            # The user probably cleared the field.  That's fine, we'll just
+            #  convert that to {}
+            hiera_yaml = {}
+        hiera_dump = yaml.safe_dump(hiera_yaml, default_flow_style=False)
+        hieraurl = "%s/%s/prefix/%s/hiera" % (self.apiurl,
+                                              self.tenant_id,
+                                              self.prefix)
+        req = requests.post(hieraurl,
+                            verify=False,
+                            data=hiera_dump,
+                            headers={'Content-Type': 'application/x-yaml'})
+        req.raise_for_status()
+        self.refresh()
+
+    @staticmethod
+    def delete_prefix(tenant_id, prefix):
+        apiurl = getattr(settings,
+                         "PUPPET_CONFIG_BACKEND",
+                         "http://labcontrol1001.wikimedia.org:8100/v1";)
+        prefixurl = "%s/%s/prefix/%s" % (apiurl, tenant_id, prefix)
+        req = requests.delete(prefixurl, verify=False)
+        req.raise_for_status()
+
+    @staticmethod
+    def get_prefixes(tenant_id):
+        apiurl = getattr(settings,
+                         "PUPPET_CONFIG_BACKEND",
+                         "http://labcontrol1001.wikimedia.org:8100/v1";)
+        prefixurl = "%s/%s/prefix" % (apiurl, tenant_id)
+        req = requests.get(prefixurl, verify=False)
+        if req.status_code == 404:
+            return []
+        else:
+            req.raise_for_status()
+        return yaml.safe_load(req.text)['prefixes']
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/puppet_roles.py 
b/modules/openstack/files/mitaka/horizon/puppettab/puppet_roles.py
new file mode 100644
index 0000000..11c72a8
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/puppet_roles.py
@@ -0,0 +1,144 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    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 logging
+import requests
+
+from django.conf import settings
+from django.core.cache import cache
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+# A single puppet class or role, used as the data type
+#  for our Horizon table-of-roles UI
+class PuppetClass():
+    name = None
+    html_name = ""
+    docs = ""
+    applied = False
+    params = []
+    formatted_params = ""
+    raw_params = {}
+    filter_tags = []
+    instance = None
+
+    def __init__(self, name):
+        self.name = name
+        self.html_name = ""
+        self.docs = _('(No docs available)')
+        self.applied = False
+        self.params = []
+        self.formatted_params = ""
+        self.raw_params = {}
+        self.filter_tags = []
+        self.instance = None
+
+    def update_prefix_data(self, prefix, tenant_id):
+        self.prefix = prefix
+        self.tenant_id = tenant_id
+        return self
+
+    def mark_applied(self, paramdict):
+        self.applied = True
+        self.params = paramdict
+        if paramdict:
+            keysanddefaults = []
+            for param in self.params.items():
+                keysanddefaults.append("%s: %s" % param)
+            self.formatted_params = ";\n".join(keysanddefaults)
+
+        return self
+
+
+# Query the puppetmaster for a list of all available roles,
+#  build a list of PuppetClass() objects out of those roles.
+#
+# This list should be fairly static and building it is
+#  expensive, so it's cached in memcache.  Local copies
+#  of this list will get altered with runtime data (e.g.
+#  tenant and instance information) but the cached version
+#  should remain useful universally.
+def available_roles():
+    key = 'wikimediapuppet_available_roles'
+    roles = cache.get(key)
+    if not roles:
+        apiurl = getattr(settings,
+                         "PUPPETMASTER_API",
+                         "https://labcontrol1001.wikimedia.org:8140/puppet";
+                         )
+        roleurl = "%s/resource_types/role" % apiurl
+        req = requests.get(roleurl, verify=False)
+        req.raise_for_status()
+        roleres = req.json()
+
+        profileurl = "%s/resource_types/profile" % apiurl
+        req = requests.get(profileurl, verify=False)
+        req.raise_for_status()
+        profileres = req.json()
+
+        res = roleres + profileres
+
+        roles = []
+        for role in res:
+            if role['kind'] != 'class':
+                continue
+            obj = PuppetClass(role['name'])
+            if 'doc' in role:
+                obj.docs = role['doc']
+            if 'parameters' in role:
+                obj.params = role['parameters']
+                obj.raw_params = role['parameters']
+                keysanddefaults = []
+                for param in obj.params.items():
+                    keysanddefaults.append("%s: %s" % param)
+                obj.formatted_params = ";\n".join(keysanddefaults)
+
+            if 'doc' in role and (role['doc'].find('filtertags') != -1):
+                #  Collect filter tags from the role comment,
+                #  and generate 'newdoc' which is the docs without
+                #  the filter line.
+                newdoc = ""
+                for line in role['doc'].splitlines():
+                    n = line.find('filtertags')
+                    if n != -1:
+                        obj.filter_tags = line[(n+11):].split()
+                    else:
+                        newdoc += "%s\n" % line
+                obj.docs = newdoc
+
+            html = '<span title="%s">%s</>' % (
+                escape(obj.docs),
+                escape(obj.name)
+            )
+            obj.html_name = mark_safe(html)
+
+            roles.append(obj)
+
+        cache.set(key, roles, 300)
+
+    return roles
+
+
+def get_role_by_name(name):
+    allRoles = available_roles()
+    for role in allRoles:
+        if role.name == name:
+            return role
+    return None
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/puppet_tables.py 
b/modules/openstack/files/mitaka/horizon/puppettab/puppet_tables.py
new file mode 100644
index 0000000..8f641c7
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/puppet_tables.py
@@ -0,0 +1,137 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from collections import defaultdict
+import logging
+
+from django.core import urlresolvers
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import tables
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+class RemoveRole(tables.LinkAction):
+    name = 'remove'
+    verbose_name = _("Remove Class")
+    classes = ("ajax-modal",)
+    data_type_singular = _("Role")
+
+    policy_rules = (("compute", "compute:delete"),)
+
+    def get_link_url(self, datum):
+        url = "horizon:project:puppet:removepuppetrole"
+        kwargs = {
+            'prefix': datum.prefix,
+            'tenantid': datum.tenant_id,
+            'roleid': datum.name,
+        }
+        return urlresolvers.reverse(url, kwargs=kwargs)
+
+    def allowed(self, request, record=None):
+        return record.applied
+
+
+class ApplyRole(tables.LinkAction):
+    name = 'apply_role'
+    verbose_name = _("Apply Class")
+    classes = ("ajax-modal",)
+    icon = "plus"
+    policy_rules = (("compute", "compute:delete"),)
+
+    def get_link_url(self, datum):
+        url = "horizon:project:puppet:applypuppetrole"
+        kwargs = {
+            'prefix': datum.prefix,
+            'tenantid': datum.tenant_id,
+            'roleid': datum.name,
+        }
+        return urlresolvers.reverse(url, kwargs=kwargs)
+
+    def allowed(self, request, record=None):
+        return (not record.applied)
+
+
+def get_categories_for_role(role):
+    categories = set(['all'])
+    if 'labs-common' in role.filter_tags:
+        categories.add('common')
+    if "labs-project-%s" % role.tenant_id in role.filter_tags:
+        categories.add('project')
+    return categories
+
+
+# This shouldn't be needed, but FixedFilterAction
+#  is a bit broken and doesn't update unless we
+#  explicitly set those category-* classes.
+class UpdateRow(tables.Row):
+    ajax = True
+
+    def load_cells(self, role=None):
+        super(UpdateRow, self).load_cells(role)
+        # Tag the row with the image category for client-side filtering.
+        for cat in get_categories_for_role(self.datum):
+            self.classes.append('category-%s' % cat)
+
+
+class RoleFilter(tables.FixedFilterAction):
+    def get_fixed_buttons(self):
+        def make_dict(text, tenant, icon):
+            return dict(text=text, value=tenant, icon=icon)
+
+        buttons = [make_dict(_('project'), 'project', 'fa-star'),
+                   make_dict(_('common'), 'common', 'fa-cube'),
+                   make_dict(_('all'), 'all', 'fa-cubes')]
+        return buttons
+
+    def categorize(self, table, roles):
+        filtered_dict = defaultdict(list)
+        for role in roles:
+            categories = get_categories_for_role(role)
+            for cat in categories:
+                filtered_dict[cat].append(role)
+        return filtered_dict
+
+
+class PuppetTable(tables.DataTable):
+    applied = tables.Column('applied', verbose_name=_('Applied'), status=True)
+    name = tables.Column('html_name',
+                         verbose_name=_('Name'))
+    params = tables.Column('formatted_params',
+                           verbose_name=_('Parameters'),
+                           sortable=False)
+    instance = tables.Column('instance',
+                             verbose_name=_('Instance'),
+                             hidden=True)
+    tenant = tables.Column('tenant',
+                           verbose_name=_('Tenant'),
+                           hidden=True)
+    tenant = tables.Column('prefix',
+                           verbose_name=_('prefix'),
+                           hidden=True)
+    roleid = tables.Column('name', verbose_name=_('ID'), hidden=True)
+
+    class Meta(object):
+        name = 'puppet'
+        row_actions = (ApplyRole, RemoveRole)
+        table_actions = (RoleFilter,)
+        status_columns = ["applied"]
+        row_class = UpdateRow
+        multi_select = False
+
+    def get_object_id(self, datum):
+        return datum.name
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/static/dashboard/puppet/puppet.scss
 
b/modules/openstack/files/mitaka/horizon/puppettab/static/dashboard/puppet/puppet.scss
new file mode 100644
index 0000000..97e7174
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/static/dashboard/puppet/puppet.scss
@@ -0,0 +1 @@
+#puppet.table-striped tbody tr.status_up td {background:lightgreen}
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/tab.py 
b/modules/openstack/files/mitaka/horizon/puppettab/tab.py
new file mode 100644
index 0000000..e137e86
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/tab.py
@@ -0,0 +1,140 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    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 logging
+
+from django.core import urlresolvers
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import tabs
+from django.conf import settings
+from django.utils.safestring import mark_safe
+
+import puppet_tables as p_tables
+from puppet_config import puppet_config
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+class PuppetTab(tabs.TableTab):
+    name = _("Puppet Configuration")
+    slug = "puppet"
+    table_classes = (p_tables.PuppetTable,)
+    template_name = "project/puppet/_detail_puppet.html"
+    preload = False
+
+    def __init__(self, *args, **kwargs):
+        # For some reason our parent class can't deal with these
+        #  args, so extract them now if they're present
+        if 'prefix' in kwargs:
+            self.prefix = kwargs['prefix']
+            self.name = self.prefix
+            del kwargs['prefix']
+
+        if 'tenant_id' in kwargs:
+            self.tenant_id = kwargs['tenant_id']
+            del kwargs['tenant_id']
+
+        if hasattr(self, 'tenant_id') and hasattr(self, 'prefix'):
+            self.slug += '-%s' % self.prefix
+            self.tab_type = 'prefix'
+
+        super(PuppetTab, self).__init__(*args, **kwargs)
+
+        if 'instance' in self.tab_group.kwargs:
+            self.tab_type = 'instance'
+            tld = getattr(settings,
+                          "INSTANCE_TLD",
+                          "eqiad.wmflabs")
+            self.instance = self.tab_group.kwargs['instance']
+
+            self.prefix = "%s.%s.%s" % (self.instance.name,
+                                        self.instance.tenant_id, tld)
+            self.tenant_id = self.instance.tenant_id
+
+        elif 'tenant_id' in self.tab_group.kwargs:
+            self.tab_type = 'project'
+            self.tenant_id = self.tab_group.kwargs['tenant_id']
+            self.prefix = self.tab_group.kwargs['prefix']
+        else:
+            self.tab_type = 'prefix'
+
+        self.add_caption()
+
+        self.config = puppet_config(self.prefix, self.tenant_id)
+
+    def add_caption(self):
+        self.capption = ""
+        if self.tab_type == 'prefix':
+            self.caption = _("These puppet settings will affect all VMs in the"
+                             " %s project whose names begin with \'%s\'.") % (
+                self.tenant_id, self.prefix)
+
+        elif self.tab_type == 'project':
+            self.caption = _("These puppet settings will affect all VMs"
+                             " in the %s project.") % self.tenant_id
+
+        elif self.tab_type == 'instance':
+            prefixes = puppet_config.get_prefixes(self.tenant_id)
+            links = []
+            for prefix in prefixes:
+                if '.' in prefix:
+                    continue
+                if prefix == '_':
+                    links.append("<a href=\"%s\">project config</a>" %
+                                 urlresolvers.reverse(
+                                     "horizon:project:puppet:index"))
+                elif self.instance.name.startswith(prefix):
+                    prefix_url = urlresolvers.reverse(
+                        "horizon:project:prefixpuppet:index",
+                        ) + "?tab=prefix_puppet__puppet-%s" % prefix
+                    links.append("<a href=\"%s\">%s</a>" % (prefix_url,
+                                                            prefix))
+
+            if links:
+                self.caption = mark_safe(_("This instance is also "
+                                           "affected by the following puppet "
+                                           "configs:  %s" % ", ".join(links)))
+
+    def get_context_data(self, request, **kwargs):
+        context = super(PuppetTab, self).get_context_data(request, **kwargs)
+        context['prefix'] = self.prefix
+        context['config'] = self.config
+        context['prefix_tab'] = (self.tab_type == 'prefix')
+
+        if hasattr(self, 'caption'):
+            context['caption'] = self.caption
+        elif 'caption' in self.tab_group.kwargs:
+            context['caption'] = self.tab_group.kwargs['caption']
+
+        kwargs = {
+            'prefix': self.prefix,
+            'tenantid': self.tenant_id,
+        }
+        context['edithieraurl'] = urlresolvers.reverse(
+            "horizon:project:puppet:edithiera", kwargs=kwargs)
+        context['editotherclassesurl'] = urlresolvers.reverse(
+            "horizon:project:puppet:editotherclasses", kwargs=kwargs)
+
+        url = "horizon:project:puppet:removepuppetprefix"
+        context['removepuppetprefixurl'] = urlresolvers.reverse(url,
+                                                                kwargs=kwargs)
+
+        return context
+
+    def get_puppet_data(self):
+        return [role.update_prefix_data(self.prefix, self.tenant_id) for
+                role in self.config.allroles]
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_apply.html 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_apply.html
new file mode 100644
index 0000000..570138e
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_apply.html
@@ -0,0 +1,25 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n horizon humanize %}
+
+{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
+
+{% block modal-header %}{% trans "Apply" %} {{ puppetrole.name }} {% endblock 
%}
+
+{% block modal-body %}
+<div class="row">
+ <div class="col-sm-6" style="width: {% if puppetrole.docs %}38{% else %}100{% 
endif %}%">
+  <h3>{{ ParamsCaption }}</h3>
+  <fieldset>
+   {% include "horizon/common/_form_fields.html" %}
+  </fieldset>
+ </div>
+ {% if puppetrole.docs %}
+  <div class="col-sm-6" style="width: 62%;">
+   <h3>{{ DocsCaption }}</h3>
+   <p>
+    <font size="1"><pre>{{ puppetrole.docs }}</pre></font>
+   </p>
+  </div>
+ {% endif %}
+</div>
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_detail_puppet.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_detail_puppet.html
new file mode 100644
index 0000000..ce53ad3
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_detail_puppet.html
@@ -0,0 +1,17 @@
+{% load i18n %}
+
+<br>
+{{ caption }}
+{% if prefix_tab %}
+<br>
+<a href="{{ removepuppetprefixurl }}" class="btn btn-primary ajax-modal">{% 
trans "Remove prefix" %}</a>
+{% endif %}
+
+<div class="row-fluid">
+  <div class="span12">
+    <h3>{% trans "Roles and Profiles" %}</h3>
+    {{ table.render }}
+  </div>
+  {% include 'project/puppet/_other_classes.html' %}
+  {% include 'project/puppet/_hiera.html' %}
+</div>
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_edithiera.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_edithiera.html
new file mode 100644
index 0000000..b9d6be2
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_edithiera.html
@@ -0,0 +1,4 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n horizon humanize %}
+
+{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_editotherclasses.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_editotherclasses.html
new file mode 100644
index 0000000..ffbc95a
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_editotherclasses.html
@@ -0,0 +1,3 @@
+{% extends "horizon/common/_modal_form.html" %}
+
+{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_hiera.html 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_hiera.html
new file mode 100644
index 0000000..7a89c64
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_hiera.html
@@ -0,0 +1,10 @@
+{% load i18n %}
+
+<div class="row-fluid">
+   <h3>{% trans "Hiera Config" %}</h3>
+   <p>
+     <pre>{{ config.hiera }}</pre>
+   </p>
+   <a href="{{ edithieraurl }}" class="btn btn-primary ajax-modal">{% trans 
"Edit" %}</a>
+</div>
+
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_other_classes.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_other_classes.html
new file mode 100644
index 0000000..1c4099d
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_other_classes.html
@@ -0,0 +1,9 @@
+{% load i18n %}
+
+<div class="row-fluid">
+   <h3>{% trans "Other Classes" %}</h3>
+   <p>
+     <pre>{{ config.other_classes_text }}</pre>
+   </p>
+   <a href="{{ editotherclassesurl }}" class="btn btn-primary ajax-modal">{% 
trans "Edit" %}</a>
+</div>
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_remove.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_remove.html
new file mode 100644
index 0000000..b3c8816
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_remove.html
@@ -0,0 +1,22 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n horizon humanize %}
+
+{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
+
+{% block modal-header %}{% trans "Remove" %} {{ puppetrole.name }} {% endblock 
%}
+
+{% block modal-body %}
+<div class="row">
+ <div class="col-sm-6" style="width: {% if puppetrole.docs %}38{% else %}100{% 
endif %}%">
+  <h3>Are you sure you want to remove this role?</h3>
+ </div>
+ {% if puppetrole.docs %}
+  <div class="col-sm-6" style="width: 62%;">
+   <h3>{{ DocsCaption }}</h3>
+   <p>
+    <font size="1"><pre>{{ puppetrole.docs }}</pre></font>
+   </p>
+  </div>
+ {% endif %}
+</div>
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_removeprefix.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_removeprefix.html
new file mode 100644
index 0000000..00413aa
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/_removeprefix.html
@@ -0,0 +1,15 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n horizon humanize %}
+
+{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
+
+{% block modal-header %}{% trans "Remove" %} {{ prefix }} {% endblock %}
+
+{% block modal-body %}
+<div class="row">
+ <div class="col-sm-6" style="width: 100%">
+  <h3>{% trans "Are you sure you want to remove this prefix?" %}</h3>
+  {% trans "All hiera and puppet roles associated with this prefix will be 
discarded." %}
+ </div>
+</div>
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/apply.html 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/apply.html
new file mode 100644
index 0000000..a4e7d38
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/apply.html
@@ -0,0 +1,8 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Apply A Role" %}{% endblock %}
+
+{% block main %}
+  {% include 'project/puppet/_apply.html' %}
+{% endblock %}
+
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/edithiera.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/edithiera.html
new file mode 100644
index 0000000..2416c95
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/edithiera.html
@@ -0,0 +1,6 @@
+{% extends 'base.html' %}
+{% load i18n %}
+
+{% block main %}
+  {% include 'project/puppet/_edithiera.html' %}
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/editotherclasses.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/editotherclasses.html
new file mode 100644
index 0000000..55b0a00
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/editotherclasses.html
@@ -0,0 +1,5 @@
+{% extends 'base.html' %}
+
+{% block main %}
+  {% include 'project/puppet/_editotherclasses.html' %}
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/plus_tab.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/plus_tab.html
new file mode 100644
index 0000000..f5338a3
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/plus_tab.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+
+{% if prefix_name %}
+Name: {{ prefix_name }}
+{% else %}
+<br>
+{% trans "A prefix must start with a letter and consist of only letters, 
numbers, - and _." %}
+<form action="" method="POST">
+    {% csrf_token %}
+    <input type="hidden" name="action" value="puppetprefixplus__undefined">
+    <input type="text" name="prefix_name" placeholder="prefix name:" 
pattern="^[A-Za-z][A-Za-z0-9-_]*$">
+    <input type="submit" value="Add prefix"><br>
+</form>
+{% endif %}
+
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/prefix_panel.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/prefix_panel.html
new file mode 100644
index 0000000..6d74e64
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/prefix_panel.html
@@ -0,0 +1,14 @@
+{% extends 'base.html' %}
+{% load i18n %}
+
+{% block title %}
+  {{ page_title }}
+{% endblock %}
+
+{% block main %}
+  <div class="row">
+    <div class="col-sm-12">
+      {{ tab_group.render }}
+    </div>
+  </div>
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/project_panel.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/project_panel.html
new file mode 100644
index 0000000..6d74e64
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/project_panel.html
@@ -0,0 +1,14 @@
+{% extends 'base.html' %}
+{% load i18n %}
+
+{% block title %}
+  {{ page_title }}
+{% endblock %}
+
+{% block main %}
+  <div class="row">
+    <div class="col-sm-12">
+      {{ tab_group.render }}
+    </div>
+  </div>
+{% endblock %}
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/remove.html 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/remove.html
new file mode 100644
index 0000000..a8e6cd8
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/remove.html
@@ -0,0 +1,8 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Remove A Role" %}{% endblock %}
+
+{% block main %}
+  {% include 'project/puppet/_remove.html' %}
+{% endblock %}
+
diff --git 
a/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/removeprefix.html
 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/removeprefix.html
new file mode 100644
index 0000000..4ab70e4
--- /dev/null
+++ 
b/modules/openstack/files/mitaka/horizon/puppettab/templates/puppet/removeprefix.html
@@ -0,0 +1,8 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Remove A Puppet Rule Prefix" %}{% endblock %}
+
+{% block main %}
+  {% include 'project/puppet/_remove.html' %}
+{% endblock %}
+
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/urls.py 
b/modules/openstack/files/mitaka/horizon/puppettab/urls.py
new file mode 100644
index 0000000..190a9c7
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/urls.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from django.conf.urls import url, patterns
+
+from wikimediapuppettab import projectpanel
+from wikimediapuppettab import views
+
+urlpatterns = patterns(
+    '',
+    url(r'^$', projectpanel.IndexView.as_view(), name='index'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        '(?P<roleid>[^/]+)/applypuppetrole$',
+        views.ApplyRoleView.as_view(), name='applypuppetrole'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        '(?P<roleid>[^/]+)/removepuppetrole$',
+        views.RemoveRoleView.as_view(), name='removepuppetrole'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        'edithiera$',
+        views.EditHieraView.as_view(), name='edithiera'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        'editotherclasses$',
+        views.EditOtherClassesView.as_view(), name='editotherclasses'),
+    url(r'^(?P<prefix>[^/]+)/(?P<tenantid>[^/]+)/'
+        'removepuppetprefix$',
+        views.RemovePrefixView.as_view(), name='removepuppetprefix'),
+)
diff --git a/modules/openstack/files/mitaka/horizon/puppettab/views.py 
b/modules/openstack/files/mitaka/horizon/puppettab/views.py
new file mode 100644
index 0000000..1eaf027
--- /dev/null
+++ b/modules/openstack/files/mitaka/horizon/puppettab/views.py
@@ -0,0 +1,319 @@
+# Copyright (c) 2016 Andrew Bogott for Wikimedia Foundation
+# All Rights Reserved.
+#
+#    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 logging
+
+from django.core import urlresolvers
+from django.core.validators import URLValidator
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import forms
+
+from puppet_config import puppet_config
+
+import puppet_roles
+
+import yaml
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+
+
+class EditHieraForm(forms.SelfHandlingForm):
+    prefix = forms.CharField(widget=forms.HiddenInput())
+    tenant_id = forms.CharField(widget=forms.HiddenInput())
+    hieradata = forms.CharField(label=_("Instance hiera config:"),
+                                widget=forms.Textarea(attrs={
+                                                      'cols': 80,
+                                                      'rows': 15}),
+                                required=False)
+
+    def handle(self, request, data):
+        config = puppet_config(data['prefix'], data['tenant_id'])
+        config.set_hiera(yaml.safe_load(data['hieradata']))
+        return True
+
+
+class EditHieraView(forms.ModalFormView):
+    form_class = EditHieraForm
+    form_id = "edit_hiera_form"
+    modal_header = _("Edit Hiera")
+    submit_label = _("Apply Changes")
+    submit_url = "horizon:project:puppet:edithiera"
+    template_name = "project/puppet/edithiera.html"
+    context_object_name = 'hieraconfig'
+
+    def get_context_data(self, **kwargs):
+        context = super(EditHieraView, self).get_context_data(**kwargs)
+        context['prefix'] = self.prefix
+        context['hieradata'] = self.hieradata.hiera
+        urlkwargs = {
+            'prefix': self.prefix,
+            'tenantid': self.tenant_id,
+        }
+        context['submit_url'] = urlresolvers.reverse(self.submit_url,
+                                                     kwargs=urlkwargs)
+        return context
+
+    def get_success_url(self):
+        validate = URLValidator()
+        refer = self.request.META.get('HTTP_REFERER', '/')
+        validate(refer)
+        return refer
+
+    def get_prefix(self):
+        return self.kwargs['prefix']
+
+    def get_tenant_id(self):
+        return self.kwargs['tenantid']
+
+    def get_initial(self):
+        initial = {}
+        self.prefix = self.get_prefix()
+        self.tenant_id = self.get_tenant_id()
+        self.hieradata = puppet_config(self.prefix, self.tenant_id)
+        initial['hieradata'] = self.hieradata.hiera
+        initial['prefix'] = self.prefix
+        initial['tenant_id'] = self.tenant_id
+
+        return initial
+
+
+class RoleViewBase(forms.ModalFormView):
+    context_object_name = 'puppetrole'
+
+    puppetrole_name = forms.CharField(widget=forms.HiddenInput())
+
+    def get_context_data(self, **kwargs):
+        context = super(RoleViewBase, self).get_context_data(**kwargs)
+        context['puppetrole'] = self.puppet_role
+        urlkwargs = {
+            'prefix': self.prefix,
+            'tenantid': self.tenant_id,
+            'roleid': self.role_id,
+        }
+        context['prefix'] = self.prefix
+        context['submit_url'] = urlresolvers.reverse(self.submit_url,
+                                                     kwargs=urlkwargs)
+        if self.puppet_role.docs:
+            context['DocsCaption'] = _('Description:')
+        else:
+            context['DocsCaption'] = _('(No Description)')
+        if self.puppet_role.params:
+            context['ParamsCaption'] = _('Parameters:')
+        else:
+            context['ParamsCaption'] = _('(No Parameters)')
+        return context
+
+    def get_success_url(self):
+        validate = URLValidator()
+        refer = self.request.META.get('HTTP_REFERER', '/')
+        validate(refer)
+        return refer
+
+    def get_puppet_role(self):
+        rolename = self.kwargs['roleid']
+        puppet_role = puppet_roles.get_role_by_name(rolename)
+        return puppet_role
+
+    def get_prefix(self):
+        return self.kwargs['prefix']
+
+    def get_tenant_id(self):
+        return self.kwargs['tenantid']
+
+    def get_initial(self):
+        initial = {}
+        self.prefix = self.get_prefix()
+        self.tenant_id = self.get_tenant_id()
+        self.role_id = self.kwargs['roleid']
+        self.puppet_role = self.get_puppet_role()
+        initial['puppet_role'] = self.puppet_role
+        initial['tenant_id'] = self.tenant_id
+        initial['prefix'] = self.prefix
+        return initial
+
+
+class ApplyRoleForm(forms.SelfHandlingForm):
+    def __init__(self, request, *args, **kwargs):
+        super(ApplyRoleForm, self).__init__(request, *args, **kwargs)
+        initial = kwargs.get('initial', {})
+        self.tenant_id = initial['tenant_id']
+        self.prefix = initial['prefix']
+        self.role = initial['puppet_role']
+        if self.role.params:
+            for key in self.role.params.keys():
+                defaultval = self.role.params.get(key, '')
+                if defaultval:
+                    defaultval = "default: %s" % defaultval
+                self.fields[key] = forms.CharField(
+                    label=mark_safe("%s  <i><small>%s</small></i>" % (
+                        key,
+                        defaultval)),
+                    required=False
+                )
+
+    def handle(self, request, data):
+        config = puppet_config(self.prefix, self.tenant_id)
+        config.apply_role(self.role, data)
+        return True
+
+
+class ApplyRoleView(RoleViewBase):
+    form_class = ApplyRoleForm
+    form_id = "apply_role_form"
+    modal_header = _("Apply Class")
+    submit_label = _("Apply")
+    submit_url = "horizon:project:puppet:applypuppetrole"
+    template_name = "project/puppet/apply.html"
+
+
+class RemoveRoleForm(forms.SelfHandlingForm):
+    def __init__(self, request, *args, **kwargs):
+        super(RemoveRoleForm, self).__init__(request, *args, **kwargs)
+        initial = kwargs.get('initial', {})
+        self.tenant_id = initial['tenant_id']
+        self.prefix = initial['prefix']
+        self.role = initial['puppet_role']
+
+    def handle(self, request, data):
+        config = puppet_config(self.prefix, self.tenant_id)
+        config.remove_role(self.role)
+        return True
+
+
+class RemoveRoleView(RoleViewBase):
+    form_class = RemoveRoleForm
+    form_id = "remove_role_form"
+    modal_header = _("Remove Class")
+    submit_label = _("Remove")
+    submit_url = "horizon:project:puppet:removepuppetrole"
+    template_name = "project/puppet/remove.html"
+
+
+class RemovePrefixForm(forms.SelfHandlingForm):
+    def __init__(self, request, *args, **kwargs):
+        super(RemovePrefixForm, self).__init__(request, *args, **kwargs)
+        initial = kwargs.get('initial', {})
+        self.tenant_id = initial['tenant_id']
+        self.prefix = initial['prefix']
+
+    def handle(self, request, data):
+        puppet_config.delete_prefix(self.tenant_id, self.prefix)
+        return True
+
+
+class RemovePrefixView(forms.ModalFormView):
+    form_class = RemovePrefixForm
+    form_id = "remove_prefix_form"
+    modal_header = _("Remove Prefix")
+    submit_label = _("Remove")
+    submit_url = "horizon:project:puppet:removepuppetprefix"
+    template_name = "project/puppet/removeprefix.html"
+
+    def get_prefix(self):
+        return self.kwargs['prefix']
+
+    def get_tenant_id(self):
+        return self.kwargs['tenantid']
+
+    def get_initial(self):
+        initial = {}
+        self.prefix = self.get_prefix()
+        self.tenant_id = self.get_tenant_id()
+        initial['prefix'] = self.prefix
+        initial['tenant_id'] = self.tenant_id
+
+        return initial
+
+    def get_context_data(self, **kwargs):
+        context = super(RemovePrefixView, self).get_context_data(**kwargs)
+        context['prefix'] = self.prefix
+        urlkwargs = {
+            'prefix': self.prefix,
+            'tenantid': self.tenant_id,
+        }
+        context['prefix'] = self.prefix
+        context['submit_url'] = urlresolvers.reverse(self.submit_url,
+                                                     kwargs=urlkwargs)
+        return context
+
+    def get_success_url(self):
+        validate = URLValidator()
+        refer = self.request.META.get('HTTP_REFERER', '/')
+        validate(refer)
+        return refer
+
+
+class EditOtherClassesForm(forms.SelfHandlingForm):
+    prefix = forms.CharField(widget=forms.HiddenInput())
+    tenant_id = forms.CharField(widget=forms.HiddenInput())
+    classes = forms.CharField(label=_("Other classes:"),
+                              widget=forms.Textarea(attrs={
+                                                    'cols': 80,
+                                                    'rows': 15}),
+                              required=False)
+
+    def handle(self, request, data):
+        other_class_list = [cls.strip() for cls in 
data['classes'].strip().split("\n") if cls]
+        config = puppet_config(data['prefix'], data['tenant_id'])
+        config.set_other_class_list(other_class_list)
+        return True
+
+
+class EditOtherClassesView(forms.ModalFormView):
+    form_class = EditOtherClassesForm
+    form_id = "edit_otherclasses_form"
+    modal_header = _("Edit Other Classes")
+    submit_label = _("Apply Changes")
+    submit_url = "horizon:project:puppet:editotherclasses"
+    template_name = "project/puppet/editotherclasses.html"
+    context_object_name = 'otherclassesconfig'
+
+    def get_context_data(self, **kwargs):
+        context = super(EditOtherClassesView, self).get_context_data(**kwargs)
+        context['prefix'] = self.prefix
+        context['classes'] = self.classdata.other_classes
+        urlkwargs = {
+            'prefix': self.prefix,
+            'tenantid': self.tenant_id,
+        }
+        context['submit_url'] = urlresolvers.reverse(self.submit_url,
+                                                     kwargs=urlkwargs)
+        return context
+
+    def get_success_url(self):
+        validate = URLValidator()
+        refer = self.request.META.get('HTTP_REFERER', '/')
+        validate(refer)
+        return refer
+
+    def get_prefix(self):
+        return self.kwargs['prefix']
+
+    def get_tenant_id(self):
+        return self.kwargs['tenantid']
+
+    def get_initial(self):
+        initial = {}
+        self.prefix = self.get_prefix()
+        self.tenant_id = self.get_tenant_id()
+        self.classdata = puppet_config(self.prefix, self.tenant_id)
+        initial['classes'] = self.classdata.other_classes_text
+        initial['prefix'] = self.prefix
+        initial['tenant_id'] = self.tenant_id
+
+        return initial

-- 
To view, visit https://gerrit.wikimedia.org/r/333987
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I916270346bf4eed9bea1ff0dfe430379bb66267e
Gerrit-PatchSet: 1
Gerrit-Project: operations/puppet
Gerrit-Branch: production
Gerrit-Owner: Andrew Bogott <abog...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to