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 + `_. + - 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 + `_ for a user account. + - Your Koji Hub must be version 1.19 or newer in order to use the new + `listCGs `_ 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 + `_ 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