bstinson / centos / releng

Forked from centos/releng 3 years ago
Clone
Blob Blame History Raw
#!/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()