diff --git a/koji-tags/README.md b/koji-tags/README.md
new file mode 100644
index 0000000..e6bd167
--- /dev/null
+++ b/koji-tags/README.md
@@ -0,0 +1,40 @@
+# CentOS Infra koji tags tool
+
+## Purpose
+This ansible playbook will just use a list of tags/targets/tags to be inherited and update the koji instance to reflect the needed changes.
+
+## Prerequisites on the machine used to control koji
+
+ * ansible (tested with 2.9.x but should work with previous versions)
+ * git clone of this repository
+ * koji cli installed *and* configured (with a profile, see below)
+ * [koji-ansible](https://github.com/ktdreyer/koji-ansible) : For you convenience, we have included needed tools here, so that this git repo is self-contained
+
+## How to use it
+
+Assuming that we have a koji profile named "mbox" , the following should work before even trying to use the playbook:
+
+```
+koji -p mbox moshimoshi
+```
+
+If it doesn't work, don't even try to use this tool, and configure koji properly (config file, mandatory x509 tls cert/private key etc)
+
+This tool also uses the variable `koji_profile` to know which koji profile to use and also which variables file to source with lists.
+In our case with "mbox", it will source the file vars/mbox.yml (apply same logic for other profiles if needed)
+
+There is an example.yml (so for profile "example") that is provided for reference
+Once you have your vars/{{ koji_profile }}.yml ready, you can start using the tool :
+
+```
+ansible-playbook create_koji_tags.yml 
+
+```
+
+This playbook will parse the variables file containings lists and will : 
+ 
+ * create needed tags that would need to be inherited from default list
+ * create koji target (including build tag and dest tags)
+ * modify build tags based on inheritance, either coming from default list, or another list that can be used
+
+ 
diff --git a/koji-tags/create_koji_tags.yml b/koji-tags/create_koji_tags.yml
new file mode 100644
index 0000000..b8b6a04
--- /dev/null
+++ b/koji-tags/create_koji_tags.yml
@@ -0,0 +1,65 @@
+- hosts: localhost
+  gather_facts: false
+  vars:
+    koji_profile: mbox # can be updated with --extra-vars
+  tasks:
+    - name: "Loading first variables for koji instance {{ koji_profile }}"
+      include_vars:
+        file: "vars/{{ koji_profile}}.yml"
+      tags:
+        - inheritance
+
+    - name: "Creating first default tags for inheritance on [{{ koji_profile }}] koji instance"
+      koji_tag:
+        koji: "{{ koji_profile }}"
+        name: "{{ item.name }}"
+        arches: "{{ koji_arches }}"
+        state: present   
+      with_items: "{{ default_tag_inheritance_list }}"
+      loop_control:
+        label: "{{ item.name }}"
+     
+    - name: "Creating needed build tags for targets on [{{ koji_profile }}] koji instance"
+      koji_tag:
+        koji: "{{ koji_profile }}"
+        name: "{{ item.build_tag }}"
+        arches: "{{ koji_arches }}"
+        state: present   
+      with_items: "{{ koji_targets }}"
+      loop_control:
+        label: "{{ item.build_tag }} => {{ item.name }}"
+    
+    - name: "Creating needed dest tags for targets on [{{ koji_profile }}] koji instance"
+      koji_tag:
+        koji: "{{ koji_profile }}"
+        name: "{{ item.dest_tag }}"
+        arches: "{{ koji_arches }}"
+        state: present   
+      with_items: "{{ koji_targets }}"
+      loop_control:
+        label: "{{ item.dest_tag }} => {{ item.name }}"
+ 
+    - name: "Creating koji targets on on [{{ koji_profile }}] koji instance"
+      koji_target:
+        koji: "{{ koji_profile }}"
+        name: "{{ item.name }}"
+        build_tag: "{{ item.build_tag }}"
+        dest_tag: "{{ item.dest_tag }}"
+      with_items: "{{ koji_targets }}"  
+      loop_control:
+        label: "{{ item.name }}"
+
+    - name: "Adding inheritance when needed"
+      koji_tag_inheritance:
+        koji: "{{ koji_profile }}"
+        parent_tag: "{{ item.1.name }}"
+        child_tag: "{{ item.0.name }}"
+        priority: "{{ item.1.priority }}"
+      with_subelements:
+        - "{{ koji_build_tags }}"
+        - inheritance_list
+      loop_control:
+        label: "{{ item.1.name }} => {{ item.0.name }}"
+      tags:
+        - inheritance
+
diff --git a/koji-tags/library/koji_archivetype.py b/koji-tags/library/koji_archivetype.py
new file mode 100644
index 0000000..c4bae35
--- /dev/null
+++ b/koji-tags/library/koji_archivetype.py
@@ -0,0 +1,115 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_archivetype
+
+short_description: Create and manage Koji archive types
+description:
+   - Create and manage Koji archive types
+   - Your Koji Hub must be version 1.20 or newer in order to use the new
+     ``addArchiveType`` RPC.
+
+options:
+   name:
+     description:
+       - The name of the Koji archive type to create and manage.
+       - 'Example: "deb".'
+     required: true
+   description:
+     description:
+       - The human-readable description of this Koji archive type.  Koji uses
+         this value in the UI tooling that display a build's files.
+       - 'Example: "Debian packages".'
+     required: true
+   extensions:
+     description:
+       - The file extensions for this Koji archive type.
+       - 'Example: "deb" means Koji will apply this archive type to files that
+         end in ".deb".'
+     required: true
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: Add deb archive types into koji
+  hosts: localhost
+  tasks:
+    - name: Add deb archive type
+      koji_archivetype:
+        name: deb
+        description: Debian packages
+        extensions: deb
+        state: present
+
+    - name: Add dsc archive type
+      koji_archivetype:
+        name: dsc
+        description: Debian source control files
+        extensions: dsc
+        state: present
+'''
+
+RETURN = ''' # '''
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        description=dict(type='str', required=True),
+        extensions=dict(type='str', required=True),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    description = params['description']
+    extensions = params['extensions']
+    state = params['state']
+
+    session = common_koji.get_session(profile)
+
+    result = {'changed': False}
+
+    if state == 'present':
+        if not session.getArchiveType(type_name=name):
+            result['changed'] = True
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.addArchiveType(name, description, extensions)
+    elif state == 'absent':
+        module.fail_json(msg="Cannot remove Koji archive types.",
+                         changed=False, rc=1)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_btype.py b/koji-tags/library/koji_btype.py
new file mode 100644
index 0000000..e541380
--- /dev/null
+++ b/koji-tags/library/koji_btype.py
@@ -0,0 +1,94 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_btype
+
+short_description: Create and manage Koji build types
+description:
+   - Every build in Koji has a "type". This module allows you to define
+     entirely new build types in Koji. These are typically in support of
+     `content generators
+     <https://docs.pagure.org/koji/content_generators/>`_.
+   - Koji only supports adding new build types, not deleting them.
+
+options:
+   name:
+     description:
+       - The name of the Koji build type to create.
+       - 'Example: "debian".'
+     required: true
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: create a debian btype in koji
+  hosts: localhost
+  tasks:
+    - name: Create a koji debian btype
+      koji_btype:
+        name: debian
+        state: present
+'''
+
+RETURN = ''' # '''
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    state = params['state']
+
+    session = common_koji.get_session(profile)
+
+    result = {'changed': False}
+
+    if state == 'present':
+        btype_data = session.listBTypes()
+        btypes = [data['name'] for data in btype_data]
+        if name not in btypes:
+            result['changed'] = True
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.addBType(name)
+    elif state == 'absent':
+        module.fail_json(msg="Cannot remove Koji build types.",
+                         changed=False, rc=1)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_call.py b/koji-tags/library/koji_call.py
new file mode 100644
index 0000000..9eff758
--- /dev/null
+++ b/koji-tags/library/koji_call.py
@@ -0,0 +1,170 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_call
+
+short_description: Make low-level Koji API calls
+description:
+   - Call Koji's RPC API directly.
+   - Why would you use this module instead of the higher level modules like
+     koji_tag, koji_target, etc? This koji_call module has two main
+     uses-cases.
+   - 1. You may want to do something that the higher level modules do not yet
+     support. It can be easier to use this module to quickly prototype out
+     your ideas for what actions you need, and then write the Python code to
+     do it in a better way later. If you find that you need to use koji_call
+     to achieve functionality that is not yet present in the other
+     koji-ansible modules, please file a Feature Request issue in GitHub with
+     your use case.
+   - 2. You want to write some tests that verify Koji's data at a very low
+     level. For example, you may want to write an integration test to verify
+     that you've set up your Koji configuration in the way you expect.
+   - 'Note that this module will always report "changed: true" every time,
+     because it simply sends the RPC to the Koji Hub on every ansible run.
+     This module cannot understand if your chosen RPC actually "changes"
+     anything.'
+options:
+   name:
+     description:
+       - The name of the Koji RPC to send.
+       - 'Example: "getTag"'
+     required: true
+   args:
+     description:
+       - The list or dict of arguments to pass into the call.
+       - 'Example: ["f29-build"]'
+     required: false
+   login:
+     description:
+       - Whether to authenticate to Koji for this API call or not.
+         Authentication is an extra round-trip to the Hub, so it slower and
+         more load on the database. You should not authenticate if this call
+         is read-only (one of the "get" API calls). If you are doing some
+         create or write operation, you must authenticate. The default
+         behavior is to do an anonymous (non-authenticated) call.
+     choices: [true, false]
+     default: false
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+RETURNS = '''
+data:
+  description: Koji's representation of the call result
+  returned: always
+  type: various, depending on the call. Koji could return a dict, or int, or
+        None.
+  sample: {'...'}
+'''
+
+EXAMPLES = '''
+- name: call the Koji API
+  hosts: localhost
+  tasks:
+
+    - name: call the API
+      koji_call:
+        name: getTag
+        args: [f29-build]
+      register: call_result
+
+    - debug:
+        var: call_result.data
+'''
+
+
+def describe_call(name, args):
+    """ Return a human-friendly description of this call. """
+    description = '%s()' % name
+    if args:
+        if isinstance(args, dict):
+            description = '%s(**%s)' % (name, args)
+        elif isinstance(args, list):
+            description = '%s(*%s)' % (name, args)
+    return description
+
+
+def check_mode_call(name, args):
+    """
+    Describe what would have happened if we executed a Koji RPC.
+    """
+    result = {'changed': True}
+    description = describe_call(name, args)
+    result['stdout_lines'] = 'would have called %s' % description
+    return result
+
+
+def do_call(session, name, args, login):
+    """
+    Execute a Koji RPC.
+
+    :param session: Koji client session
+    :param str name: Name of the RPC
+    :param args: list or dict of arguments to this RPC.
+    :param bool login: Whether to log in for this call or not.
+    """
+    result = {'changed': True}
+    if login:
+        common_koji.ensure_logged_in(session)
+    call = getattr(session, name)
+    if isinstance(args, dict):
+        data = call(**args)
+    else:
+        data = call(*args)
+    result['data'] = data
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        args=dict(type='raw', required=False, default=[]),
+        login=dict(type='bool', required=False, default=False),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    args = params['args']
+    login = params['login']
+
+    if args and not isinstance(args, (dict, list)):
+        msg = "args must be a list or dictionary, not %s" % type(args)
+        module.fail_json(msg=msg, changed=False, rc=1)
+
+    session = common_koji.get_session(profile)
+
+    if module.check_mode:
+        result = check_mode_call(name, args)
+    else:
+        result = do_call(session, name, args, login)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_cg.py b/koji-tags/library/koji_cg.py
new file mode 100644
index 0000000..60ff399
--- /dev/null
+++ b/koji-tags/library/koji_cg.py
@@ -0,0 +1,181 @@
+#!/usr/bin/python
+import sys
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_cg
+
+short_description: Create and manage Koji content generators
+description:
+   - This module can grant or revoke access to a `content generator
+     <https://docs.pagure.org/koji/content_generators/>`_ for a user account.
+   - Your Koji Hub must be version 1.19 or newer in order to use the new
+     `listCGs <https://pagure.io/koji/pull-request/1160>`_ RPC.
+
+
+options:
+   name:
+     description:
+       - The name of the Koji content generator.
+       - 'Example: "debian".'
+     required: true
+   user:
+     description:
+       - The name of the Koji user account.
+       - This user account must already exist in Koji's database. For example,
+         you may run an authenticated "koji hello" command to create the
+         account database entry.
+       - 'Example: "cguser".'
+     required: true
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: Grant a user access to a content generator.
+  hosts: localhost
+  tasks:
+    - name: Grant access to the rcm/debbuild account
+      koji_cg:
+        name: debian
+        user: rcm/debbuild
+        state: present
+'''
+
+RETURN = ''' # '''
+
+
+class UnknownCGsError(Exception):
+    """ We cannot know what CGs are present """
+    pass
+
+
+def list_cgs(session):
+    """ Return the result of listCGs, or raise UnknownCGsError """
+    koji_profile = sys.modules[session.__module__]
+    try:
+        return session.listCGs()
+    except koji_profile.GenericError as e:
+        if str(e) == 'Invalid method: listCGs':
+            # Kojihub before version 1.20 will raise this error.
+            raise UnknownCGsError
+        raise
+
+
+def ensure_cg(session, user, name, state, cgs, check_mode):
+    """
+    Ensure that a content generator and user is present or absent.
+
+    :param session: koji ClientSession
+    :param str user: koji user name
+    :param str name: content generator name
+    :param str state: "present" or "absent"
+    :param dict cgs: existing content generators and users
+    :param bool check_mode: if True, show what would happen, but don't do it.
+    :returns: result
+    """
+    result = {'changed': False}
+    if state == 'present':
+        if name not in cgs or user not in cgs[name]['users']:
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.grantCGAccess(user, name, create=True)
+            result['changed'] = True
+    elif state == 'absent':
+        if name in cgs and user in cgs[name]['users']:
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.revokeCGAccess(user, name)
+            result['changed'] = True
+    return result
+
+
+def ensure_unknown_cg(session, user, name, state):
+    """
+    Ensure that a content generator and user is present or absent.
+
+    This method is for older versions of Koji where we do not have the listCGs
+    RPC. This method does not support check_mode.
+
+    :param session: koji ClientSession
+    :param str user: koji user name
+    :param str name: content generator name
+    :param str state: "present" or "absent"
+    :returns: result
+    """
+    result = {'changed': False}
+    koji_profile = sys.modules[session.__module__]
+    common_koji.ensure_logged_in(session)
+    if state == 'present':
+        # The "grant" method will at least raise an error if the permission
+        # was already granted, so we can set the "changed" result based on
+        # that.
+        try:
+            session.grantCGAccess(user, name, create=True)
+            result['changed'] = True
+        except koji_profile.GenericError as e:
+            if 'User already has access to content generator' not in str(e):
+                raise
+    elif state == 'absent':
+        # There's no indication whether this changed anything, so we're going
+        # to be pessimistic and say we're always changing it.
+        session.revokeCGAccess(user, name)
+        result['changed'] = True
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        user=dict(type='str', required=True),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    user = params['user']
+    state = params['state']
+
+    session = common_koji.get_session(profile)
+
+    try:
+        cgs = list_cgs(session)
+        result = ensure_cg(session, user, name, state, cgs, check_mode)
+    except UnknownCGsError:
+        if check_mode:
+            msg = 'check mode does not work without listCGs'
+            result = {'changed': False, 'msg': msg}
+        else:
+            result = ensure_unknown_cg(session, user, name, state)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_external_repo.py b/koji-tags/library/koji_external_repo.py
new file mode 100644
index 0000000..4ca103d
--- /dev/null
+++ b/koji-tags/library/koji_external_repo.py
@@ -0,0 +1,148 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_external_repo
+
+short_description: Create and manage Koji external repos
+description:
+   - Create and manage Koji external repos
+options:
+   name:
+     description:
+       - The name of the Koji external repo to create and manage.
+     required: true
+   url:
+     description:
+       - The URL to the Koji external repo.
+       - Note, this uses "$arch", not the common "$basearch" you may find in a
+         typical Yum repository file.
+       - For idempotency, please ensure your url always ends with a "/"
+         character. If you leave it out, Koji Hub will automatically add a "/"
+         slash when storing this value in the database, and every subsequent
+         Ansible run will appear to be "changing" the external repo's URL.
+     required: true
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: create a koji tag with an external repo.
+  hosts: localhost
+  tasks:
+    - name: Create an external repo for CentOS "CR"
+      koji_external_repo:
+        name: centos7-cr
+        url: http://mirror.centos.org/centos/7/cr/$arch/
+        state: present
+
+    - name: Create a koji tag that uses the CentOS CR repo
+      koji_tag:
+        name: storage7-ceph-nautilus-el7-build
+        state: present
+        external_repos:
+        - repo: centos7-cr
+          priority: 5
+'''
+
+RETURN = ''' # '''
+
+
+def ensure_external_repo(session, name, check_mode, url):
+    """
+    Ensure that this external repo exists in Koji.
+
+    :param session: Koji client session
+    :param name: Koji external repo name
+    :param check_mode: don't make any changes
+    :param url: URL to this external repo
+    """
+    repoinfo = session.getExternalRepo(name)
+    result = {'changed': False, 'stdout_lines': []}
+    if not repoinfo:
+        if check_mode:
+            result['stdout_lines'].append('would create repo %s' % name)
+            result['changed'] = True
+            return result
+        common_koji.ensure_logged_in(session)
+        repoinfo = session.createExternalRepo(name, url)
+        result['stdout_lines'].append('created repo id %d' % repoinfo['id'])
+        result['changed'] = True
+        return result
+    if repoinfo['url'] != url:
+        result['stdout_lines'].append('set url to %s' % url)
+        result['changed'] = True
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.editExternalRepo(info=repoinfo['id'], url=url)
+    return result
+
+
+def delete_external_repo(session, name, check_mode):
+    """ Ensure that this external_repo is deleted from Koji. """
+    repoinfo = session.getExternalRepo(name)
+    result = dict(
+        stdout='',
+        changed=False,
+    )
+    if repoinfo:
+        result['stdout'] = 'deleted external repo %s' % name
+        result['changed'] = True
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.deleteExternalRepo(name)
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+        url=dict(type='str', required=False, default=None),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    state = params['state']
+    url = params['url']
+
+    session = common_koji.get_session(profile)
+
+    if state == 'present':
+        if not url:
+            module.fail_json(msg='you must set a url for this external_repo')
+        result = ensure_external_repo(session, name, check_mode, url)
+    elif state == 'absent':
+        result = delete_external_repo(session, name, check_mode)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_host.py b/koji-tags/library/koji_host.py
new file mode 100644
index 0000000..9e43574
--- /dev/null
+++ b/koji-tags/library/koji_host.py
@@ -0,0 +1,234 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_host
+
+short_description: Create and manage Koji build hosts
+description:
+   - This module can add new hosts and manage existing hosts.
+   - 'Koji only supports adding new hosts, not deleting them. Once they are
+     defined, you can enable or disable the hosts with "state: enabled" or
+     "state: disabled".'
+
+options:
+   name:
+     description:
+       - The name of the Koji builder.
+       - 'Example: "builder1.example.com".'
+     required: true
+   arches:
+     description:
+       - The list of arches this host supports.
+       - 'Example: [x86_64]'
+     required: true
+   channels:
+     description:
+       - The list of channels this host should belong to.
+       - If you specify a completely new channel here, Ansible will create the
+         channel on the hub. For example, when you set up OSBS with Koji, you
+         must add a builder host to a new "container" channel. You can simply
+         specify "container" in the list here, and Ansible will create the new
+         "container" channel when it adds your host to that channel.
+       - 'Example: [default, createrepo]'
+     required: false
+   state:
+     description:
+       - Whether to set this host as "enabled" or "disabled". If unset, this
+         defaults to "enabled".
+   krb_principal:
+     description:
+       - Set a non-default krb principal for this host. If unset, Koji will
+         use the standard krb principal scheme for builder accounts.
+   capacity:
+     description:
+       - Total task weight for this host. This is a float value. If unset,
+         Koji will use the standard capacity for a host (2.0).
+       - 'Example: 10.0'
+   description:
+     description:
+       - Human-readable description for this host.
+   comment:
+     description:
+       - Human-readable comment explaining the current state of the host. You
+         may write a description here explaining how this host was set up, or
+         why this host is currently offline.
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: create a koji host
+  hosts: localhost
+  tasks:
+    - name: Add new builder1 host
+      koji_host:
+        name: builder1.example.com
+        arches: [x86_64]
+        state: enabled
+        channels:
+          - createrepo
+          - default
+
+    - name: Add new builder host for OSBS
+      koji_host:
+        name: containerbuild1.example.com
+        arches: [x86_64]
+        state: enabled
+        channels:
+          # This will automatically create the "container" channel
+          # if it does not already exist:
+          - container
+'''
+
+
+def ensure_channels(session, host_id, host_name, check_mode, desired_channels):
+    """
+    Ensure that given host belongs to given channels (and only them).
+
+    :param session: Koji client session
+    :param int host_id: Koji host ID
+    :param int host_name: Koji host name
+    :param bool check_mode: don't make any changes
+    :param list desired_channels: channels that the host should belong to
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    common_koji.ensure_logged_in(session)
+    current_channels = session.listChannels(host_id)
+    current_channels = [channel['name'] for channel in current_channels]
+    for channel in current_channels:
+        if channel not in desired_channels:
+            if not check_mode:
+                session.removeHostFromChannel(host_name, channel)
+            result['stdout_lines'].append('removed host from channel %s' % channel)
+            result['changed'] = True
+    for channel in desired_channels:
+        if channel not in current_channels:
+            if not check_mode:
+                session.addHostToChannel(host_name, channel, create=True)
+            result['stdout_lines'].append('added host to channel %s' % channel)
+            result['changed'] = True
+    return result
+
+
+def ensure_host(session, name, check_mode, state, arches, krb_principal,
+                channels, **kwargs):
+    """
+    Ensure that this host is configured in Koji.
+
+    :param session: Koji client session
+    :param str name: Koji builder host name
+    :param bool check_mode: don't make any changes
+    :param str state: "enabled" or "disabled"
+    :param list arches: list of arches for this builder.
+    :param str krb_principal: custom kerberos principal, or None
+    :param list chanels: list of channels this host should belong to.
+    :param **kwargs: Pass remaining kwargs directly into Koji's editHost RPC.
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    host = session.getHost(name)
+    if not host:
+        result['changed'] = True
+        result['stdout_lines'].append('created host')
+        if check_mode:
+            return result
+        common_koji.ensure_logged_in(session)
+        id_ = session.addHost(name, arches, krb_principal)
+        host = session.getHost(id_)
+    if state == 'enabled':
+        if not host['enabled']:
+            result['changed'] = True
+            result['stdout_lines'].append('enabled host')
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.enableHost(name)
+    elif state == 'disabled':
+        if host['enabled']:
+            result['changed'] = True
+            result['stdout_lines'].append('disabled host')
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.disableHost(name)
+    edits = {}
+    if ' '.join(arches) != host['arches']:
+        edits['arches'] = ' '.join(arches)
+    for key, value in kwargs.items():
+        if value is None:
+            continue  # Ansible did not set this parameter.
+        if key in host and kwargs[key] != host[key]:
+            edits[key] = value
+    if edits:
+        result['changed'] = True
+        for edit in edits.keys():
+            result['stdout_lines'].append('edited host %s' % edit)
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.editHost(name, **edits)
+
+    # Ensure host is member of desired channels.
+    if channels not in (None, ''):
+        channels_result = ensure_channels(session, host['id'],
+                                          name, check_mode, channels)
+        if channels_result['changed']:
+            result['changed'] = True
+        result['stdout_lines'].extend(channels_result['stdout_lines'])
+
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        arches=dict(type='list', required=True),
+        channels=dict(type='list', required=False, default=None),
+        krb_principal=dict(type='str', required=False, default=None),
+        capacity=dict(type='float', required=False, default=None),
+        description=dict(type='str', required=False, default=None),
+        comment=dict(type='str', required=False, default=None),
+        state=dict(type='str', choices=[
+                   'enabled', 'disabled'], required=False, default='enabled'),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    state = params['state']
+
+    session = common_koji.get_session(profile)
+
+    result = ensure_host(session, name, check_mode, state,
+                         arches=params['arches'],
+                         channels=params['channels'],
+                         krb_principal=params['krb_principal'],
+                         capacity=params['capacity'],
+                         description=params['description'],
+                         comment=params['comment'])
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_tag.py b/koji-tags/library/koji_tag.py
new file mode 100644
index 0000000..5e78359
--- /dev/null
+++ b/koji-tags/library/koji_tag.py
@@ -0,0 +1,590 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from collections import defaultdict
+from ansible.module_utils import common_koji
+from ansible.module_utils.six import string_types
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_tag
+
+short_description: Create and manage Koji tags
+description:
+   - Create and manage Koji tags
+options:
+   name:
+     description:
+       - The name of the Koji tag to create and manage.
+     required: true
+   inheritance:
+     description:
+       - How to set inheritance. what happens when it's unset.
+   external_repos:
+     description:
+       - list of Koji external repos to set for this tag. Each element of the
+         list should have a "repo" (the external repo name) and "priority"
+         (integer).
+   packages:
+     description:
+       - dict of package owners and the a lists of packages each owner
+         maintains.
+   groups:
+     description:
+       - A tag's "groups" tell Koji what packages will be present in the
+         tag's buildroot. For example, the "build" group defines the packages
+         that Koji will put into a "build" task's buildroot. You may set other
+         package groups on a tag as well, like "srpm-build" or
+         "applicance-build".
+       - This should be a dict of groups and packages to set for this tag.
+         Each dict key will be the name of the group. Each dict value should
+         be a list of package names to include in the comps for this group.
+       - If a group or package defined in this field is already applicable for
+         a tag due to inheritance, Koji will not allow it to be added to the
+         tag, but will instead silently ignore it. Conversely, groups and
+         packages that are inherited in this field are not removed if they are
+         left unspecified. Therefore, this field will only have an effect if it
+         includes groups and packages that are unique to this tag (i.e., not
+         inherited).
+       - This does not support advanced comps group operations, like
+         configuring extra options on groups, or blocking packages in groups.
+         If you need that level of control over comps groups, you will need
+         to import a full comps XML file, outside of this Ansible module.
+   arches:
+     description:
+       - space-separated string of arches this Koji tag supports.
+       - Note, the order in which you specify architectures does matter in a
+         few subtle cases. For example, the SRPM that Koji includes in the
+         build is the one built on the first arch in this list. Likewise,
+         rpmdiff compares RPMs built on the first arch with RPMs built on
+         other arches.
+   perm:
+     description:
+       - permission (string or int) for this Koji tag.
+   locked:
+     description:
+       - whether to lock this tag or not.
+     choices: [true, false]
+     default: false
+   maven_support:
+     description:
+       - whether Maven repos should be generated for the tag.
+     choices: [true, false]
+     default: false
+   maven_include_all:
+     description:
+       - include every build in this tag (including multiple versions of the
+         same package) in the Maven repo.
+     choices: [true, false]
+     default: false
+   extra:
+     description:
+       - set any extra parameters on this tag.
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: create a main koji tag and candidate tag
+  hosts: localhost
+  tasks:
+    - name: Create a main product koji tag
+      koji_tag:
+        koji: kojidev
+        name: ceph-3.1-rhel-7
+        arches: x86_64
+        state: present
+        packages:
+          kdreyer:
+            - ansible
+            - ceph
+            - ceph-ansible
+
+    - name: Create a candidate koji tag
+      koji_tag:
+        koji: kojidev
+        name: ceph-3.1-rhel-7-candidate
+        state: present
+        inheritance:
+        - parent: ceph-3.1-rhel-7
+          priority: 0
+
+    - name: Create a tag that uses an external repo
+      koji_tag:
+        koji: kojidev
+        name: storage7-ceph-nautilus-el7-build
+        state: present
+        external_repos:
+        - repo: centos7-cr
+          priority: 5
+
+    - name: Create a tag that uses comps groups
+      koji_tag:
+        name: foo-el7-build
+        groups:
+          srpm-build:
+            - rpm-build
+            - fedpkg
+'''
+
+RETURN = ''' # '''
+
+
+class DuplicateNameError(Exception):
+    """ The user specified two external repos with the same name. """
+    pass
+
+
+class DuplicatePriorityError(Exception):
+    """ The user specified two external repos with the same priority. """
+    pass
+
+
+def validate_repos(repos):
+    """Ensure that each external repository has unique name and priority
+    values.
+
+    This prevents the user from accidentally specifying two or more external
+    repositories with the same name or priority.
+
+    :param repos: list of repository dicts
+    :raises: DuplicatePriorityError if two repos have the same priority.
+    """
+    names = set()
+    priorities = set()
+    for repo in repos:
+        name = repo['repo']
+        priority = repo['priority']
+        if name in names:
+            raise DuplicateNameError(name)
+        if priority in priorities:
+            raise DuplicatePriorityError(priority)
+        names.add(name)
+        priorities.add(priority)
+
+
+def normalize_inheritance(inheritance):
+    """
+    Transform inheritance module argument input into the format returned by
+    getInheritanceData(). Only includes supported fields (excluding child_id
+    and parent_id), renames "parent" to "name", chooses correct defaults for
+    missing fields, and performs some limited type correction for maxdepth and
+    priority.
+
+    :param inheritance: list of inheritance dicts
+    """
+    normalized_inheritance = []
+
+    for rule in inheritance:
+        # maxdepth: treat empty strings the same as None
+        maxdepth = rule.get('maxdepth')
+        if maxdepth == '':
+            maxdepth = None
+        if isinstance(maxdepth, string_types):
+            maxdepth = int(maxdepth)
+
+        normalized_inheritance.append(dict(
+            # we don't know child_id yet
+            intransitive=rule.get('intransitive', False),
+            maxdepth=maxdepth,
+            name=rule['parent'],
+            noconfig=rule.get('noconfig', False),
+            # we don't know parent_id yet
+            pkg_filter=rule.get('pkg_filter', ''),
+            priority=int(rule['priority']),
+        ))
+
+    return sorted(normalized_inheritance, key=lambda i: i['priority'])
+
+
+def ensure_inheritance(session, tag_name, tag_id, check_mode, inheritance):
+    """
+    Ensure that these inheritance rules are configured on this Koji tag.
+
+    :param session: Koji client session
+    :param str tag_name: Koji tag name
+    :param int tag_id: Koji tag ID
+    :param bool check_mode: don't make any changes
+    :param list inheritance: ensure these rules are set, and no others
+    """
+    result = {'changed': False, 'stdout_lines': []}
+
+    # resolve parent tag IDs
+    rules = []
+    for rule in normalize_inheritance(inheritance):
+        parent_name = rule['name']
+        parent_taginfo = session.getTag(parent_name)
+        if not parent_taginfo:
+            msg = "parent tag '%s' not found" % parent_name
+            if check_mode:
+                result['stdout_lines'].append(msg)
+                # spoof to allow continuation
+                parent_taginfo = {'id': 0}
+            else:
+                raise ValueError(msg)
+        parent_id = parent_taginfo['id']
+        rules.append(dict(rule, child_id=tag_id, parent_id=parent_id))
+
+    current_inheritance = session.getInheritanceData(tag_name)
+    if current_inheritance != rules:
+        result['stdout_lines'].extend(
+                ('current inheritance:',)
+                + common_koji.describe_inheritance(current_inheritance)
+                + ('new inheritance:',)
+                + common_koji.describe_inheritance(rules))
+        result['changed'] = True
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.setInheritanceData(tag_name, rules, clear=True)
+    return result
+
+
+def ensure_external_repos(session, tag_name, check_mode, repos):
+    """
+    Ensure that these external repos are configured on this Koji tag.
+
+    :param session: Koji client session
+    :param str tag_name: Koji tag name
+    :param bool check_mode: don't make any changes
+    :param list repos: ensure these external repos are set, and no others.
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    validate_repos(repos)
+    current_repo_list = session.getTagExternalRepos(tag_name)
+    current = {repo['external_repo_name']: repo for repo in current_repo_list}
+    current_priorities = {
+        str(repo['priority']): repo for repo in current_repo_list
+    }
+    for repo in sorted(repos, key=lambda r: r['priority']):
+        repo_name = repo['repo']
+        repo_priority = repo['priority']
+        if repo_name in current:
+            # The repo is present for this tag.
+            # Now ensure the priority is correct.
+            if repo_priority == current[repo_name]['priority']:
+                continue
+            result['changed'] = True
+            msg = 'set %s repo priority to %i' % (repo_name, repo_priority)
+            result['stdout_lines'].append(msg)
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.editTagExternalRepo(tag_name, repo_name, repo_priority)
+            continue
+        elif str(repo_priority) in current_priorities:
+            # No need to check for name equivalence here; it would already
+            # have happened
+            result['changed'] = True
+            msg = 'set repo at priority %i to %s' % (repo_priority, repo_name)
+            result['stdout_lines'].append(msg)
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                same_priority_repo = current_priorities.get(
+                        str(repo_priority)).get('external_repo_name')
+                session.removeExternalRepoFromTag(tag_name, same_priority_repo)
+                session.addExternalRepoToTag(
+                        tag_name, repo_name, repo_priority)
+                # Prevent duplicate attempts
+                del current_priorities[str(repo_priority)]
+            continue
+        result['changed'] = True
+        msg = 'add %s external repo to %s' % (repo_name, tag_name)
+        result['stdout_lines'].append(msg)
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.addExternalRepoToTag(tag_name, repo_name, repo_priority)
+    # Find the repos to remove from this tag.
+    repo_names = [repo['repo'] for repo in repos]
+    current_names = current.keys()
+    repos_to_remove = set(current_names) - set(repo_names)
+    for repo_name in repos_to_remove:
+        result['changed'] = True
+        msg = 'removed %s repo from %s tag' % (repo_name, tag_name)
+        result['stdout_lines'].append(msg)
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.removeExternalRepoFromTag(tag_name, repo_name)
+    return result
+
+
+def ensure_packages(session, tag_name, tag_id, check_mode, packages):
+    """
+    Ensure that these packages are configured on this Koji tag.
+
+    :param session: Koji client session
+    :param str tag_name: Koji tag name
+    :param int tag_id: Koji tag ID
+    :param bool check_mode: don't make any changes
+    :param dict packages: ensure these packages are set (?)
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    # Note: this in particular could really benefit from koji's
+    # multicalls...
+    common_koji.ensure_logged_in(session)
+    current_pkgs = session.listPackages(tagID=tag_id)
+    current_names = set([pkg['package_name'] for pkg in current_pkgs])
+    # Create a "current_owned" dict to compare with what's in Ansible.
+    current_owned = defaultdict(set)
+    for pkg in current_pkgs:
+        owner = pkg['owner_name']
+        pkg_name = pkg['package_name']
+        current_owned[owner].add(pkg_name)
+    for owner, owned in packages.items():
+        for package in owned:
+            if package not in current_names:
+                # The package was missing from the tag entirely.
+                if not check_mode:
+                    session.packageListAdd(tag_name, package, owner)
+                result['stdout_lines'].append('added pkg %s' % package)
+                result['changed'] = True
+            else:
+                # The package is already in this tag.
+                # Verify ownership.
+                if package not in current_owned.get(owner, []):
+                    if not check_mode:
+                        session.packageListSetOwner(tag_name, package, owner)
+                    result['stdout_lines'].append('set %s owner %s' %
+                                                  (package, owner))
+                    result['changed'] = True
+    # Delete any packages not in Ansible.
+    all_names = [name for names in packages.values() for name in names]
+    delete_names = set(current_names) - set(all_names)
+    for package in delete_names:
+        result['stdout_lines'].append('remove pkg %s' % package)
+        result['changed'] = True
+        if not check_mode:
+            session.packageListRemove(tag_name, package, owner)
+    return result
+
+
+def ensure_groups(session, tag_id, check_mode, desired_groups):
+    """
+    Ensure that these groups are configured on this Koji tag.
+
+    :param session: Koji client session
+    :param int tag_id: Koji tag ID
+    :param bool check_mode: don't make any changes
+    :param dict desired_groups: ensure these groups are set (?)
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    common_koji.ensure_logged_in(session)
+    current_groups = session.getTagGroups(tag_id)
+    for group in current_groups:
+        if group['tag_id'] == tag_id and group['name'] not in desired_groups:
+            if not check_mode:
+                session.groupListRemove(tag_id, group['name'])
+            result['stdout_lines'].append('removed group %s' % group['name'])
+            result['changed'] = True
+    for group_name, desired_pkgs in desired_groups.items():
+        for group in current_groups:
+            if group['name'] == group_name:
+                current_pkgs = {entry['package']: entry['tag_id']
+                                for entry in group['packagelist']}
+                break
+        else:
+            current_pkgs = {}
+            if not check_mode:
+                session.groupListAdd(tag_id, group_name)
+            result['stdout_lines'].append('added group %s' % group_name)
+            result['changed'] = True
+
+        for package, pkg_tag_id in current_pkgs.items():
+            if pkg_tag_id == tag_id and package not in desired_pkgs:
+                if not check_mode:
+                    session.groupPackageListRemove(tag_id, group_name, package)
+                result['stdout_lines'].append('removed pkg %s from group %s' % (package, group_name))
+                result['changed'] = True
+        for package in desired_pkgs:
+            if package not in current_pkgs:
+                if not check_mode:
+                    session.groupPackageListAdd(tag_id, group_name, package)
+                result['stdout_lines'].append('added pkg %s to group %s' % (package, group_name))
+                result['changed'] = True
+    return result
+
+
+def compound_parameter_present(param_name, param, expected_type):
+    if param not in (None, ''):
+        if not isinstance(param, expected_type):
+            raise ValueError(param_name + ' must be a '
+                             + expected_type.__class__.__name__
+                             + ', not a ' + param.__class__.__name__)
+        return True
+    return False
+
+
+def ensure_tag(session, name, check_mode, inheritance, external_repos,
+               packages, groups, **kwargs):
+    """
+    Ensure that this tag exists in Koji.
+
+    :param session: Koji client session
+    :param name: Koji tag name
+    :param check_mode: don't make any changes
+    :param inheritance: Koji tag inheritance settings. These will be translated
+                        for Koji's setInheritanceData RPC.
+    :param external_repos: Koji external repos to set for this tag.
+    :param packages: dict of packages to add ("whitelist") for this tag.
+                     If this is an empty dict, we don't touch the package list
+                     for this tag.
+    :param groups: dict of comps groups to set for this tag.
+    :param **kwargs: Pass remaining kwargs directly into Koji's createTag and
+                     editTag2 RPCs.
+    """
+    taginfo = session.getTag(name)
+    result = {'changed': False, 'stdout_lines': []}
+    if not taginfo:
+        if check_mode:
+            result['stdout_lines'].append('would create tag %s' % name)
+            result['changed'] = True
+            return result
+        common_koji.ensure_logged_in(session)
+        if 'perm' in kwargs and kwargs['perm']:
+            kwargs['perm'] = common_koji.get_perm_id(session, kwargs['perm'])
+        id_ = session.createTag(name, parent=None, **kwargs)
+        result['stdout_lines'].append('created tag id %d' % id_)
+        result['changed'] = True
+        taginfo = {'id': id_}  # populate for inheritance management below
+    else:
+        # The tag name already exists. Ensure all the parameters are set.
+        edits = {}
+        edit_log = []
+        for key, value in kwargs.items():
+            if taginfo[key] != value and value is not None:
+                edits[key] = value
+                edit_log.append('%s: changed %s from "%s" to "%s"'
+                                % (name, key, taginfo[key], value))
+        # Find out which "extra" items we must explicitly remove
+        # ("remove_extra" argument to editTag2).
+        if 'extra' in kwargs and kwargs['extra'] is not None:
+            for key in taginfo['extra']:
+                if key not in kwargs['extra']:
+                    if 'remove_extra' not in edits:
+                        edits['remove_extra'] = []
+                    edits['remove_extra'].append(key)
+            if 'remove_extra' in edits:
+                edit_log.append('%s: remove extra fields "%s"'
+                                % (name, '", "'.join(edits['remove_extra'])))
+        if edits:
+            result['stdout_lines'].extend(edit_log)
+            result['changed'] = True
+            if not check_mode:
+                common_koji.ensure_logged_in(session)
+                session.editTag2(name, **edits)
+
+    # Ensure inheritance rules are all set.
+    if compound_parameter_present('inheritance', inheritance, list):
+        inheritance_result = ensure_inheritance(session, name, taginfo['id'],
+                                                check_mode, inheritance)
+        if inheritance_result['changed']:
+            result['changed'] = True
+        result['stdout_lines'].extend(inheritance_result['stdout_lines'])
+
+    # Ensure external repos.
+    if compound_parameter_present('external_repos', external_repos, list):
+        repos_result = ensure_external_repos(session, name, check_mode,
+                                             external_repos)
+        if repos_result['changed']:
+            result['changed'] = True
+        result['stdout_lines'].extend(repos_result['stdout_lines'])
+
+    # Ensure package list.
+    if compound_parameter_present('packages', packages, dict):
+        packages_result = ensure_packages(session, name, taginfo['id'],
+                                          check_mode, packages)
+        if packages_result['changed']:
+            result['changed'] = True
+        result['stdout_lines'].extend(packages_result['stdout_lines'])
+
+    # Ensure group list.
+    if compound_parameter_present('groups', groups, dict):
+        groups_result = ensure_groups(session, taginfo['id'],
+                                      check_mode, groups)
+        if groups_result['changed']:
+            result['changed'] = True
+        result['stdout_lines'].extend(groups_result['stdout_lines'])
+
+    return result
+
+
+def delete_tag(session, name, check_mode):
+    """ Ensure that this tag is deleted from Koji. """
+    taginfo = session.getTag(name)
+    result = dict(
+        stdout='',
+        changed=False,
+    )
+    if taginfo:
+        result['stdout'] = 'deleted tag %d' % taginfo['id']
+        result['changed'] = True
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.deleteTag(name)
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+        inheritance=dict(type='raw', required=False, default=None),
+        external_repos=dict(type='raw', required=False, default=None),
+        packages=dict(type='raw', required=False, default=None),
+        groups=dict(type='raw', required=False, default=None),
+        arches=dict(type='str', required=False, default=None),
+        perm=dict(type='str', required=False, default=None),
+        locked=dict(type='bool', required=False, default=False),
+        maven_support=dict(type='bool', required=False, default=False),
+        maven_include_all=dict(type='bool', required=False, default=False),
+        extra=dict(type='dict', required=False, default=None),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    state = params['state']
+
+    session = common_koji.get_session(profile)
+
+    if state == 'present':
+        result = ensure_tag(session, name,
+                            check_mode,
+                            inheritance=params['inheritance'],
+                            external_repos=params['external_repos'],
+                            packages=params['packages'],
+                            groups=params['groups'],
+                            arches=params['arches'],
+                            perm=params['perm'] or None,
+                            locked=params['locked'],
+                            maven_support=params['maven_support'],
+                            maven_include_all=params['maven_include_all'],
+                            extra=params['extra'])
+    elif state == 'absent':
+        result = delete_tag(session, name, check_mode)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_tag_inheritance.py b/koji-tags/library/koji_tag_inheritance.py
new file mode 100644
index 0000000..53ebc84
--- /dev/null
+++ b/koji-tags/library/koji_tag_inheritance.py
@@ -0,0 +1,329 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_tag_inheritance
+
+short_description: Manage a Koji tag inheritance relationship
+description:
+   - Fine-grained management for tag inheritance relationships.
+   - The `koji_tag` module is all-or-nothing when it comes to managing tag
+     inheritance. When you set inheritance with `koji_tag`, the module will
+     delete any inheritance relationships that are not defined there.
+   - In some cases you may want to declare *some* inheritance relationships
+     within Ansible without clobbering other existing inheritance
+     relationships. For example, `MBS
+     <https://fedoraproject.org/wiki/Changes/ModuleBuildService>`_ will
+     dynamically manage some inheritance relationships of tags.
+options:
+   child_tag:
+     description:
+       - The name of the Koji tag that will be the child.
+     required: true
+   parent_tag:
+     description:
+       - The name of the Koji tag that will be the parent of the child.
+     required: true
+   priority:
+     description:
+       - The priority of this parent for this child. Parents with smaller
+         numbers will override parents with bigger numbers.
+       - 'When defining an inheritance relationship with "state: present", you
+         must specify a priority. When deleting an inheritance relationship
+         with "state: absent", you should not specify a priority. Ansible will
+         simply remove the parent_tag link, regardless of its priority.'
+     required: true
+   maxdepth:
+     description:
+       - By default, a tag's inheritance chain is unlimited. This means that
+         Koji will look back through an unlimited chain of parent and
+         grandparent tags to determine the contents of the tag.
+       - You may use this maxdepth parameter to limit the maximum depth of the
+         inheritance. For example "0" means that only the parent tag itself
+         will be available in the inheritance - parent tags of the parent tag
+         won't be available.
+       - 'To restore the default umlimited depth behavior on a tag, you can set
+         ``maxdepth: null`` or ``maxdepth: `` (empty value).'
+       - If you do not set any ``maxdepth`` parameter at all, koji-ansible
+         will overwrite an existing tag's current maxdepth setting to "null"
+         (in other words, unlimited depth). This was the historical behavior
+         of the module and the easiest way to implement this in the code.
+         Arguably this behavior is unexpected, because Ansible should only do
+         what you tell it to do. We might change this in the future so that
+         Ansible only modifies ``maxdepth`` *if* you explicitly configure it.
+         Please open GitHub issues to discuss your use-case.
+     required: false
+     default: null (unlimited depth)
+   pkg_filter:
+     description:
+       - Regular expression selecting the packages for which builds can be
+         inherited through this inheritance link.
+       - Don't forget to use ``^`` and ``$`` when limiting to exact package
+         names; they are not implicit.
+       - The default empty string allows all packages to be inherited through
+         this link.
+     required: false
+     default: ''
+   intransitive:
+     description:
+       - Prevents inheritance link from being used by the child tag's children.
+         In other words, the link is only used to determine parent tags for the
+         child tag directly, but not to determine "grandparent" tags for the
+         child tag's children.
+     required: false
+     default: false
+   noconfig:
+     description:
+       - Prevents tag options ("extra") from being inherited.
+     required: false
+     default: false
+   state:
+     description:
+       - Whether to add or remove this inheritance link.
+     choices: [present, absent]
+     default: present
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: Use devtoolset to build for Ceph Nautilus
+  hosts: localhost
+  tasks:
+    - name: set devtoolset-7 as a parent of ceph nautilus
+      koji_tag_inheritance:
+        koji: kojidev
+        parent_tag: sclo7-devtoolset-7-rh-release
+        child_tag: storage7-ceph-nautilus-el7-build
+        priority: 25
+
+    - name: remove devtoolset-7 as a parent of my other build tag
+      koji_tag_inheritance:
+        parent_tag: sclo7-devtoolset-7-rh-release
+        child_tag: other-storage-el7-build
+        state: absent
+'''
+
+RETURN = ''' # '''
+
+
+def get_ids_and_inheritance(session, child_tag, parent_tag):
+    """
+    Query Koji for the current state of these tags and inheritance.
+
+    :param session: Koji client session
+    :param str child_tag: Koji tag name
+    :param str parent_tag: Koji tag name
+    :return: 3-element tuple of child_id (int), parent_id (int),
+             and current_inheritance (list)
+    """
+    child_taginfo = session.getTag(child_tag)
+    parent_taginfo = session.getTag(parent_tag)
+    child_id = child_taginfo['id'] if child_taginfo else None
+    parent_id = parent_taginfo['id'] if parent_taginfo else None
+    if child_id:
+        current_inheritance = session.getInheritanceData(child_id)
+    else:
+        current_inheritance = []
+    # TODO use multicall to get all of this at once:
+    # (Need to update the test suite fakes to handle multicalls)
+    # session.multicall = True
+    # session.getTag(child_tag, strict=True)
+    # session.getTag(parent_tag, strict=True)
+    # session.getInheritanceData(child_tag)
+    # multicall_results = session.multiCall(strict=True)
+    # # flatten multicall results:
+    # multicall_results = [result[0] for result in multicall_results]
+    # child_id = multicall_results[0]['id']
+    # parent_id = multicall_results[1]['id']
+    # current_inheritance = multicall_results[2]
+    return (child_id, parent_id, current_inheritance)
+
+
+def generate_new_rule(child_id, parent_tag, parent_id, priority, maxdepth,
+                      pkg_filter, intransitive, noconfig):
+    """
+    Return a full inheritance rule to add for this child tag.
+
+    :param int child_id: Koji tag id
+    :param str parent_tag: Koji tag name
+    :param int parent_id: Koji tag id
+    :param int priority: Priority of this parent for this child
+    :param int maxdepth: Max depth of the inheritance
+    :param str pkg_filter: Regular expression string of package names to include
+    :param bool intransitive: Don't allow this inheritance link to be inherited
+    :param bool noconfig: Prevent tag options ("extra") from being inherited
+    """
+    return {
+        'child_id': child_id,
+        'intransitive': intransitive,
+        'maxdepth': maxdepth,
+        'name': parent_tag,
+        'noconfig': noconfig,
+        'parent_id': parent_id,
+        'pkg_filter': pkg_filter,
+        'priority': priority}
+
+
+def add_tag_inheritance(session, child_tag, parent_tag, priority, maxdepth,
+                        pkg_filter, intransitive, noconfig, check_mode):
+    """
+    Ensure that a tag inheritance rule exists.
+
+    :param session: Koji client session
+    :param str child_tag: Koji tag name
+    :param str parent_tag: Koji tag name
+    :param int priority: Priority of this parent for this child
+    :param int maxdepth: Max depth of the inheritance
+    :param str pkg_filter: Regular expression string of package names to include
+    :param bool intransitive: Don't allow this inheritance link to be inherited
+    :param bool noconfig: Prevent tag options ("extra") from being inherited
+    :param bool check_mode: don't make any changes
+    :return: result (dict)
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    data = get_ids_and_inheritance(session, child_tag, parent_tag)
+    child_id, parent_id, current_inheritance = data
+    if not child_id:
+        msg = 'child tag %s not found' % child_tag
+        if check_mode:
+            result['stdout_lines'].append(msg)
+        else:
+            raise ValueError(msg)
+    if not parent_id:
+        msg = 'parent tag %s not found' % parent_tag
+        if check_mode:
+            result['stdout_lines'].append(msg)
+        else:
+            raise ValueError(msg)
+
+    new_rule = generate_new_rule(child_id, parent_tag, parent_id, priority,
+                                 maxdepth, pkg_filter, intransitive, noconfig)
+    new_rules = [new_rule]
+    for rule in current_inheritance:
+        if rule == new_rule:
+            return result
+        if rule['priority'] == priority:
+            # prefix taginfo-style inheritance strings with diff-like +/-
+            result['stdout_lines'].append('dissimilar rules:')
+            result['stdout_lines'].extend(
+                    map(lambda r: ' -' + r,
+                        common_koji.describe_inheritance_rule(rule)))
+            result['stdout_lines'].extend(
+                    map(lambda r: ' +' + r,
+                        common_koji.describe_inheritance_rule(new_rule)))
+            delete_rule = rule.copy()
+            # Mark this rule for deletion
+            delete_rule['delete link'] = True
+            new_rules.insert(0, delete_rule)
+
+    if len(new_rules) > 1:
+        result['stdout_lines'].append('remove inheritance link:')
+        result['stdout_lines'].extend(
+                common_koji.describe_inheritance(new_rules[:-1]))
+    result['stdout_lines'].append('add inheritance link:')
+    result['stdout_lines'].extend(
+            common_koji.describe_inheritance_rule(new_rule))
+    result['changed'] = True
+    if not check_mode:
+        common_koji.ensure_logged_in(session)
+        session.setInheritanceData(child_tag, new_rules)
+    return result
+
+
+def remove_tag_inheritance(session, child_tag, parent_tag, check_mode):
+    """
+    Ensure that a tag inheritance rule does not exist.
+
+    :param session: Koji client session
+    :param str child_tag: Koji tag name
+    :param str parent_tag: Koji tag name
+    :param bool check_mode: don't make any changes
+    :return: result (dict)
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    current_inheritance = session.getInheritanceData(child_tag)
+    found_rule = {}
+    for rule in current_inheritance:
+        if rule['name'] == parent_tag:
+            found_rule = rule.copy()
+            # Mark this rule for deletion
+            found_rule['delete link'] = True
+    if not found_rule:
+        return result
+    result['stdout_lines'].append('remove inheritance link:')
+    result['stdout_lines'].extend(
+            common_koji.describe_inheritance_rule(found_rule))
+    result['changed'] = True
+    if not check_mode:
+        common_koji.ensure_logged_in(session)
+        session.setInheritanceData(child_tag, [found_rule])
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        child_tag=dict(type='str', required=True),
+        parent_tag=dict(type='str', required=True),
+        priority=dict(type='int', required=False),
+        maxdepth=dict(type='int', required=False, default=None),
+        pkg_filter=dict(type='str', required=False, default=''),
+        intransitive=dict(type='bool', required=False, default=False),
+        noconfig=dict(type='bool', required=False, default=False),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    state = params['state']
+    profile = params['koji']
+
+    session = common_koji.get_session(profile)
+
+    if state == 'present':
+        if 'priority' not in params:
+            module.fail_json(msg='specify a "priority" integer')
+        result = add_tag_inheritance(session,
+                                     child_tag=params['child_tag'],
+                                     parent_tag=params['parent_tag'],
+                                     priority=params['priority'],
+                                     maxdepth=params['maxdepth'],
+                                     pkg_filter=params['pkg_filter'],
+                                     intransitive=params['intransitive'],
+                                     noconfig=params['noconfig'],
+                                     check_mode=check_mode)
+    elif state == 'absent':
+        result = remove_tag_inheritance(session,
+                                        child_tag=params['child_tag'],
+                                        parent_tag=params['parent_tag'],
+                                        check_mode=check_mode)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_target.py b/koji-tags/library/koji_target.py
new file mode 100644
index 0000000..14b0505
--- /dev/null
+++ b/koji-tags/library/koji_target.py
@@ -0,0 +1,150 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_target
+
+short_description: Create and manage Koji targets
+description:
+   - Create, update, and delete targets within Koji.
+options:
+   name:
+     description:
+       - The name of the Koji target to create and manage.
+     required: true
+   build_tag:
+     description:
+       - The name of the "build" or "buildroot" tag. The latest builds in
+         this tag will be available in the buildroot when you build an RPM or
+         container for this Koji target.
+       - 'Example: "f29-build"'
+     required: true
+   dest_tag:
+     description:
+       - The name of the "destination" tag. When Koji completes a build for
+         this target, it will tag that build into this destination tag.
+       - 'Example: "f29-updates-candidate"'
+     required: true
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: create a koji target
+  hosts: localhost
+  tasks:
+
+    - name: Configure CBS target
+      koji_target:
+        name: storage7-ceph-nautilus-el7
+        build_tag: storage7-ceph-nautilus-el7-build
+        dest_tag: storage7-ceph-nautilus-candidate
+'''
+
+
+def ensure_target(session, name, check_mode, build_tag, dest_tag):
+    """
+    Ensure that this target exists in Koji.
+
+    :param session: Koji client session
+    :param name: Koji target name
+    :param check_mode: don't make any changes
+    :param build_tag: Koji build tag name, eg. "f29-build"
+    :param dest_tag: Koji destination tag name, eg "f29-updates-candidate"
+    """
+    targetinfo = session.getBuildTarget(name)
+    result = {'changed': False, 'stdout_lines': []}
+    if not targetinfo:
+        result['changed'] = True
+        if check_mode:
+            result['stdout_lines'].append('would create target %s' % name)
+            return result
+        common_koji.ensure_logged_in(session)
+        session.createBuildTarget(name, build_tag, dest_tag)
+        targetinfo = session.getBuildTarget(name)
+        result['stdout_lines'].append('created target %s' % targetinfo['id'])
+    # Ensure the build and destination tags are set for this target.
+    needs_edit = False
+    if build_tag != targetinfo['build_tag_name']:
+        needs_edit = True
+        result['stdout_lines'].append('build_tag_name: %s' % build_tag)
+    if dest_tag != targetinfo['dest_tag_name']:
+        needs_edit = True
+        result['stdout_lines'].append('dest_tag_name: %s' % dest_tag)
+    if needs_edit:
+        result['changed'] = True
+        if check_mode:
+            return result
+        common_koji.ensure_logged_in(session)
+        session.editBuildTarget(name, name, build_tag, dest_tag)
+    return result
+
+
+def delete_target(session, name, check_mode):
+    """ Ensure that this tag is deleted from Koji. """
+    targetinfo = session.getBuildTarget(name)
+    result = dict(
+        stdout='',
+        changed=False,
+    )
+    if targetinfo:
+        result['stdout'] = 'deleted target %d' % targetinfo['id']
+        result['changed'] = True
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+            session.deleteBuildTarget(targetinfo['id'])
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        state=dict(type='str', choices=[
+                   'present', 'absent'], required=False, default='present'),
+        build_tag=dict(type='str', required=True),
+        dest_tag=dict(type='str', required=True),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    state = params['state']
+    build_tag = params['build_tag']
+    dest_tag = params['dest_tag']
+
+    session = common_koji.get_session(profile)
+
+    if state == 'present':
+        result = ensure_target(session, name, check_mode, build_tag, dest_tag)
+    elif state == 'absent':
+        result = delete_target(session, name, check_mode)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/library/koji_user.py b/koji-tags/library/koji_user.py
new file mode 100644
index 0000000..aa46f31
--- /dev/null
+++ b/koji-tags/library/koji_user.py
@@ -0,0 +1,154 @@
+#!/usr/bin/python
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils import common_koji
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.0',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+
+DOCUMENTATION = '''
+---
+module: koji_user
+
+short_description: Create and manage Koji user accounts
+description:
+   - This module can add new users and manage existing users.
+   - 'Koji only supports adding new users, not deleting them. Once they are
+     defined, you can enable or disable the users with "state: enabled" or
+     "state: disabled".'
+
+options:
+   name:
+     description:
+       - The name of the Koji user.
+       - 'Example: "kdreyer".'
+     required: true
+   state:
+     description:
+       - Whether to set this user as "enabled" or "disabled". If unset, this
+         defaults to "enabled".
+   permissions:
+     description:
+       - A list of permissions for this user.
+       - 'Example: [admin]'
+   krb_principal:
+     description:
+       - Set a non-default krb principal for this user. If unset, Koji will
+         use the standard krb principal scheme for user accounts.
+       - Warning, Koji only allows you to set this one time, at the point at
+         which you create the new account. You cannot edit the krb_principal
+         for an existing account.
+requirements:
+  - "python >= 2.7"
+  - "koji"
+'''
+
+EXAMPLES = '''
+- name: create a koji user
+  hosts: localhost
+  tasks:
+    - name: Add new kdreyer user
+      koji_user:
+        name: kdreyer
+        state: enabled
+        permissions: [admin]
+'''
+
+
+def ensure_user(session, name, check_mode, state, permissions, krb_principal):
+    """
+    Ensure that this user is configured in Koji.
+
+    :param session: Koji client session
+    :param str name: Koji builder user name
+    :param bool check_mode: don't make any changes
+    :param str state: "enabled" or "disabled"
+    :param list permissions: list of permissions for this user.
+    :param str krb_principal: custom kerberos principal, or None. Used only at
+                              account creation time.
+    """
+    result = {'changed': False, 'stdout_lines': []}
+    if state == 'enabled':
+        desired_status = common_koji.koji.USER_STATUS['NORMAL']
+    else:
+        desired_status = common_koji.koji.USER_STATUS['BLOCKED']
+    user = session.getUser(name)
+    if not user:
+        result['changed'] = True
+        result['stdout_lines'] = ['created %s user' % name]
+        if check_mode:
+            return result
+        common_koji.ensure_logged_in(session)
+        id_ = session.createUser(name, desired_status, krb_principal)
+        user = session.getUser(id_)
+    if user['status'] != desired_status:
+        result['changed'] = True
+        result['stdout_lines'] = ['%s %s user' % (state, name)]
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+        if state == 'enabled':
+            session.enableUser(name)
+        else:
+            session.disableUser(name)
+    if not permissions:
+        return result
+    current_perms = session.getUserPerms(user['id'])
+    to_grant = set(permissions) - set(current_perms)
+    to_revoke = set(current_perms) - set(permissions)
+    if to_grant or to_revoke:
+        result['changed'] = True
+        if not check_mode:
+            common_koji.ensure_logged_in(session)
+    for permission in to_grant:
+        result['stdout_lines'].append('grant %s' % permission)
+        if not check_mode:
+            session.grantPermission(name, permission, True)
+    for permission in to_revoke:
+        result['stdout_lines'].append('revoke %s' % permission)
+        if not check_mode:
+            session.revokePermission(name, permission)
+    return result
+
+
+def run_module():
+    module_args = dict(
+        koji=dict(type='str', required=False),
+        name=dict(type='str', required=True),
+        permissions=dict(type='list', required=True),
+        krb_principal=dict(type='str', required=False, default=None),
+        state=dict(type='str', choices=[
+                   'enabled', 'disabled'], required=False, default='enabled'),
+    )
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    if not common_koji.HAS_KOJI:
+        module.fail_json(msg='koji is required for this module')
+
+    check_mode = module.check_mode
+    params = module.params
+    profile = params['koji']
+    name = params['name']
+    state = params['state']
+
+    session = common_koji.get_session(profile)
+
+    result = ensure_user(session, name, check_mode, state,
+                         permissions=params['permissions'],
+                         krb_principal=params['krb_principal'])
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/koji-tags/module_utils/common_koji.py b/koji-tags/module_utils/common_koji.py
new file mode 100644
index 0000000..ecf9740
--- /dev/null
+++ b/koji-tags/module_utils/common_koji.py
@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+import os
+try:
+    import koji
+    from koji_cli.lib import activate_session
+    HAS_KOJI = True
+except ImportError:
+    HAS_KOJI = False
+
+
+def get_profile_name(profile):
+    """
+    Return a koji profile name.
+
+    :param str profile: profile name, like "koji" or "cbs", or None. If None,
+                        we will use return the "KOJI_PROFILE" environment
+                        variable. If we could find no profile name, raise
+                        ValueError.
+    :returns: str, the profile name
+    """
+    if profile:
+        return profile
+    profile = os.getenv('KOJI_PROFILE')
+    if profile:
+        return profile
+    raise ValueError('set a profile "koji" argument for this task, or set '
+                     'the KOJI_PROFILE environment variable')
+
+
+def get_session(profile):
+    """
+    Return an anonymous koji session for this profile name.
+
+    :param str profile: profile name, like "koji" or "cbs". If None, we will
+                        use a profile name from the "KOJI_PROFILE" environment
+                        variable.
+    :returns: anonymous koji.ClientSession
+    """
+    profile = get_profile_name(profile)
+    # Note, get_profile_module() raises koji.ConfigurationError if we
+    # could not find this profile's name in /etc/koji.conf.d/*.conf and
+    # ~/.koji/config.d/*.conf.
+    mykoji = koji.get_profile_module(profile)
+    # Workaround https://pagure.io/koji/issue/1022 . Koji 1.17 will not need
+    # this.
+    if '~' in str(mykoji.config.cert):
+        mykoji.config.cert = os.path.expanduser(mykoji.config.cert)
+    if '~' in str(mykoji.config.ca):
+        mykoji.config.ca = os.path.expanduser(mykoji.config.ca)
+    # Note, Koji has a grab_session_options() method that can also create a
+    # stripped-down dict of our module's (OptParse) configuration, like:
+    #   opts = mykoji.grab_session_options(mykoji.config)
+    # The idea is that callers then pass that opts dict into ClientSession's
+    # constructor.
+    # There are two reasons we don't use that here:
+    # 1. The dict is only suitable for the ClientSession(..., opts), not for
+    #    activate_session(..., opts). activate_session() really wants the full
+    #    set of key/values in mykoji.config.
+    # 2. We may call activate_session() later outside of this method, so we
+    #    need to preserve all the configuration data from mykoji.config inside
+    #    the ClientSession object. We might as well just store it in the
+    #    ClientSession's .opts and then pass that into activate_session().
+    opts = vars(mykoji.config)
+    # Force an anonymous session (noauth):
+    opts['noauth'] = True
+    session = mykoji.ClientSession(mykoji.config.server, opts)
+    # activate_session with noauth will simply ensure that we can connect with
+    # a getAPIVersion RPC. Let's avoid it here because it just slows us down.
+    # activate_session(session, opts)
+    return session
+
+
+def ensure_logged_in(session):
+    """
+    Authenticate this Koji session (if necessary).
+
+    :param session: a koji.ClientSession
+    :returns: None
+    """
+    if not session.logged_in:
+        session.opts['noauth'] = False
+        # Log in ("activate") this session:
+        # Note: this can raise SystemExit if there is a problem, eg with
+        # Kerberos:
+        activate_session(session, session.opts)
+
+
+# inheritance display utils
+
+
+def describe_inheritance_rule(rule):
+    """
+    Given a dictionary representing a koji inheritance rule (i.e., one of the
+    elements of getInheritanceData()'s result), return a tuple of strings to be
+    appended to a module's stdout_lines array conforming to the output of
+    koji's taginfo CLI command, e.g.:
+       0   .... a-parent-tag
+      10   M... another-parent-tag
+        maxdepth: 1
+     100   .F.. yet-another-parent-tag
+        package filter: ^prefix-
+    """
+    # koji_cli/commands.py near the end of anon_handle_taginfo()
+    flags = '%s%s%s%s' % (
+        'M' if rule['maxdepth'] not in ('', None) else '.',
+        'F' if rule['pkg_filter'] not in ('', None) else '.',
+        'I' if rule['intransitive'] else '.',
+        'N' if rule['noconfig'] else '.',
+    )
+
+    result = ["%4d   %s %s" % (rule['priority'], flags, rule['name'])]
+
+    if rule['maxdepth'] not in ('', None):
+        result.append("    maxdepth: %d" % rule['maxdepth'])
+    if rule['pkg_filter'] not in ('', None):
+        result.append("    package filter: %s" % rule['pkg_filter'])
+
+    return tuple(result)
+
+
+def describe_inheritance(rules):
+    """
+    Given a sequence of dictionaries representing koji inheritance rules (i.e.,
+    getInheritanceData()'s result), return a tuple of strings to be appended to
+    a module's stdout_lines array conforming to the output of koji's taginfo
+    CLI command. See describe_inheritance_rule for sample output.
+    """
+
+    # each invocation of describe_inheritance_rule yields a tuple of strings
+    # to be appended to a module's stdout_lines result, so concatenate them:
+    # sum(…, tuple()) will flatten tuples of tuples into just the child tuples
+    # > sum( ((1, 2), (3, 4)), tuple() ) ⇒ (1, 2) + (3, 4) + (,) ⇒ (1, 2, 3, 4)
+    return sum(tuple(map(describe_inheritance_rule, rules)), tuple())
+
+
+# permission utils
+
+
+perm_cache = {}
+
+
+def get_perms(session):
+    global perm_cache
+    if not perm_cache:
+        perm_cache = dict([
+            (perm['name'], perm['id']) for perm in session.getAllPerms()
+        ])
+    return perm_cache
+
+
+def get_perm_id(session, name):
+    perms = get_perms(session)
+    return perms[name]
+
+
+def get_perm_name(session, id_):
+    perms = get_perms(session)
+    for perm_name, perm_id in perms.items():
+        if perm_id == id_:
+            return perm_name
diff --git a/koji-tags/vars/example.yml b/koji-tags/vars/example.yml
new file mode 100644
index 0000000..61e608d
--- /dev/null
+++ b/koji-tags/vars/example.yml
@@ -0,0 +1,39 @@
+# This vars file will be included for koji profile [example]
+# It has to exist on execution node (and koji configured properly)
+
+# List of architectures supported on this koji instance
+koji_arches: x86_64 ppc64le aarch64
+
+# List of tags automatically added in build tags if no list is provided
+default_tag_inheritance_list:
+  - name: el8_1
+    priority: 5
+  - name: dist-c8-updates
+    priority: 10
+  - name: dist-c8
+    priority: 15
+  - name: module-javapackages-tools-201801-8000020190620195035-211ef2cd
+    priority: 20
+  - name: module-pki-deps-10.6-8000020190624155206-d1bfb1e2
+    priority: 21
+
+# List of koji targets, where we'll submit builds
+koji_targets:
+  - name: dist-c8_1-updates
+    build_tag: dist-c8_1-updates-build
+    dest_tag: dist-c8-updates
+  - name: dist-c8_2-updates
+    build_tag: dist-c8_2-updates-build
+    dest_tag: dist-c8-updates
+
+# List of build tags used in targets, but also configured with default inheritance or overriding it
+koji_build_tags:
+  - name: dist-c8_2-updates-build #inheriting default_tag_inheritance_list
+    inheritance_list: "{{ default_tag_inheritance_list }}"
+  - name: zzz-rh-ruby999-rh
+    inheritance_list:
+      - name: storage7-gluster-7-testing
+        priority: 2
+      - name: test77-common-candidate
+        priority: 5
+
diff --git a/koji-tags/vars/mbox.yml b/koji-tags/vars/mbox.yml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/koji-tags/vars/mbox.yml