diff options
Diffstat (limited to 'lib/ansible/modules/extras/packaging/os/openbsd_pkg.py')
-rw-r--r-- | lib/ansible/modules/extras/packaging/os/openbsd_pkg.py | 522 |
1 files changed, 522 insertions, 0 deletions
diff --git a/lib/ansible/modules/extras/packaging/os/openbsd_pkg.py b/lib/ansible/modules/extras/packaging/os/openbsd_pkg.py new file mode 100644 index 0000000000..59fdd35c26 --- /dev/null +++ b/lib/ansible/modules/extras/packaging/os/openbsd_pkg.py @@ -0,0 +1,522 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2013, Patrik Lundin <patrik@sigterm.se> +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. + +import os +import platform +import re +import shlex +import sqlite3 + +from distutils.version import StrictVersion + +DOCUMENTATION = ''' +--- +module: openbsd_pkg +author: "Patrik Lundin (@eest)" +version_added: "1.1" +short_description: Manage packages on OpenBSD. +description: + - Manage packages on OpenBSD using the pkg tools. +requirements: [ "python >= 2.5" ] +options: + name: + required: true + description: + - Name of the package. + state: + required: true + choices: [ present, latest, absent ] + description: + - C(present) will make sure the package is installed. + C(latest) will make sure the latest version of the package is installed. + C(absent) will make sure the specified package is not installed. + build: + required: false + choices: [ yes, no ] + default: no + description: + - Build the package from source instead of downloading and installing + a binary. Requires that the port source tree is already installed. + Automatically builds and installs the 'sqlports' package, if it is + not already installed. + version_added: "2.1" + ports_dir: + required: false + default: /usr/ports + description: + - When used in combination with the 'build' option, allows overriding + the default ports source directory. + version_added: "2.1" +''' + +EXAMPLES = ''' +# Make sure nmap is installed +- openbsd_pkg: name=nmap state=present + +# Make sure nmap is the latest version +- openbsd_pkg: name=nmap state=latest + +# Make sure nmap is not installed +- openbsd_pkg: name=nmap state=absent + +# Make sure nmap is installed, build it from source if it is not +- openbsd_pkg: name=nmap state=present build=yes + +# Specify a pkg flavour with '--' +- openbsd_pkg: name=vim--no_x11 state=present + +# Specify the default flavour to avoid ambiguity errors +- openbsd_pkg: name=vim-- state=present + +# Specify a package branch (requires at least OpenBSD 6.0) +- openbsd_pkg: name=python%3.5 state=present + +# Update all packages on the system +- openbsd_pkg: name=* state=latest +''' + +# Function used for executing commands. +def execute_command(cmd, module): + # Break command line into arguments. + # This makes run_command() use shell=False which we need to not cause shell + # expansion of special characters like '*'. + cmd_args = shlex.split(cmd) + return module.run_command(cmd_args) + +# Function used to find out if a package is currently installed. +def get_package_state(name, pkg_spec, module): + info_cmd = 'pkg_info -Iq' + + command = "%s inst:%s" % (info_cmd, name) + + rc, stdout, stderr = execute_command(command, module) + + if stderr: + module.fail_json(msg="failed in get_package_state(): " + stderr) + + if stdout: + # If the requested package name is just a stem, like "python", we may + # find multiple packages with that name. + pkg_spec['installed_names'] = [name for name in stdout.splitlines()] + module.debug("get_package_state(): installed_names = %s" % pkg_spec['installed_names']) + return True + else: + return False + +# Function used to make sure a package is present. +def package_present(name, installed_state, pkg_spec, module): + build = module.params['build'] + + if module.check_mode: + install_cmd = 'pkg_add -Imn' + else: + if build is True: + port_dir = "%s/%s" % (module.params['ports_dir'], get_package_source_path(name, pkg_spec, module)) + if os.path.isdir(port_dir): + if pkg_spec['flavor']: + flavors = pkg_spec['flavor'].replace('-', ' ') + install_cmd = "cd %s && make clean=depends && FLAVOR=\"%s\" make install && make clean=depends" % (port_dir, flavors) + elif pkg_spec['subpackage']: + install_cmd = "cd %s && make clean=depends && SUBPACKAGE=\"%s\" make install && make clean=depends" % (port_dir, pkg_spec['subpackage']) + else: + install_cmd = "cd %s && make install && make clean=depends" % (port_dir) + else: + module.fail_json(msg="the port source directory %s does not exist" % (port_dir)) + else: + install_cmd = 'pkg_add -Im' + + if installed_state is False: + + # Attempt to install the package + if build is True and not module.check_mode: + (rc, stdout, stderr) = module.run_command(install_cmd, module, use_unsafe_shell=True) + else: + (rc, stdout, stderr) = execute_command("%s %s" % (install_cmd, name), module) + + # The behaviour of pkg_add is a bit different depending on if a + # specific version is supplied or not. + # + # When a specific version is supplied the return code will be 0 when + # a package is found and 1 when it is not. If a version is not + # supplied the tool will exit 0 in both cases. + # + # It is important to note that "version" relates to the + # packages-specs(7) notion of a version. If using the branch syntax + # (like "python%3.5") the version number is considered part of the + # stem, and the pkg_add behavior behaves the same as if the name did + # not contain a version (which it strictly speaking does not). + if pkg_spec['version'] or build is True: + # Depend on the return code. + module.debug("package_present(): depending on return code") + if rc: + changed=False + else: + # Depend on stderr instead. + module.debug("package_present(): depending on stderr") + if stderr: + # There is a corner case where having an empty directory in + # installpath prior to the right location will result in a + # "file:/local/package/directory/ is empty" message on stderr + # while still installing the package, so we need to look for + # for a message like "packagename-1.0: ok" just in case. + match = re.search("\W%s-[^:]+: ok\W" % name, stdout) + if match: + # It turns out we were able to install the package. + module.debug("package_present(): we were able to install package") + pass + else: + # We really did fail, fake the return code. + module.debug("package_present(): we really did fail") + rc = 1 + changed=False + else: + module.debug("package_present(): stderr was not set") + + if rc == 0: + if module.check_mode: + module.exit_json(changed=True) + + changed=True + + else: + rc = 0 + stdout = '' + stderr = '' + changed=False + + return (rc, stdout, stderr, changed) + +# Function used to make sure a package is the latest available version. +def package_latest(name, installed_state, pkg_spec, module): + + if module.params['build'] is True: + module.fail_json(msg="the combination of build=%s and state=latest is not supported" % module.params['build']) + + if module.check_mode: + upgrade_cmd = 'pkg_add -umn' + else: + upgrade_cmd = 'pkg_add -um' + + pre_upgrade_name = '' + + if installed_state is True: + + # Attempt to upgrade the package. + (rc, stdout, stderr) = execute_command("%s %s" % (upgrade_cmd, name), module) + + # Look for output looking something like "nmap-6.01->6.25: ok" to see if + # something changed (or would have changed). Use \W to delimit the match + # from progress meter output. + changed = False + for installed_name in pkg_spec['installed_names']: + module.debug("package_latest(): checking for pre-upgrade package name: %s" % installed_name) + match = re.search("\W%s->.+: ok\W" % installed_name, stdout) + if match: + module.debug("package_latest(): pre-upgrade package name match: %s" % installed_name) + if module.check_mode: + module.exit_json(changed=True) + + changed = True + break + + # FIXME: This part is problematic. Based on the issues mentioned (and + # handled) in package_present() it is not safe to blindly trust stderr + # as an indicator that the command failed, and in the case with + # empty installpath directories this will break. + # + # For now keep this safeguard here, but ignore it if we managed to + # parse out a successful update above. This way we will report a + # successful run when we actually modify something but fail + # otherwise. + if changed != True: + if stderr: + rc=1 + + return (rc, stdout, stderr, changed) + + else: + # If package was not installed at all just make it present. + module.debug("package_latest(): package is not installed, calling package_present()") + return package_present(name, installed_state, pkg_spec, module) + +# Function used to make sure a package is not installed. +def package_absent(name, installed_state, module): + if module.check_mode: + remove_cmd = 'pkg_delete -In' + else: + remove_cmd = 'pkg_delete -I' + + if installed_state is True: + + # Attempt to remove the package. + rc, stdout, stderr = execute_command("%s %s" % (remove_cmd, name), module) + + if rc == 0: + if module.check_mode: + module.exit_json(changed=True) + + changed=True + else: + changed=False + + else: + rc = 0 + stdout = '' + stderr = '' + changed=False + + return (rc, stdout, stderr, changed) + +# Function used to parse the package name based on packages-specs(7). +# The general name structure is "stem-version[-flavors]". +# +# Names containing "%" are a special variation not part of the +# packages-specs(7) syntax. See pkg_add(1) on OpenBSD 6.0 or later for a +# description. +def parse_package_name(name, pkg_spec, module): + module.debug("parse_package_name(): parsing name: %s" % name) + # Do some initial matches so we can base the more advanced regex on that. + version_match = re.search("-[0-9]", name) + versionless_match = re.search("--", name) + + # Stop if someone is giving us a name that both has a version and is + # version-less at the same time. + if version_match and versionless_match: + module.fail_json(msg="package name both has a version and is version-less: " + name) + + # If name includes a version. + if version_match: + match = re.search("^(?P<stem>.*)-(?P<version>[0-9][^-]*)(?P<flavor_separator>-)?(?P<flavor>[a-z].*)?$", name) + if match: + pkg_spec['stem'] = match.group('stem') + pkg_spec['version_separator'] = '-' + pkg_spec['version'] = match.group('version') + pkg_spec['flavor_separator'] = match.group('flavor_separator') + pkg_spec['flavor'] = match.group('flavor') + pkg_spec['style'] = 'version' + else: + module.fail_json(msg="unable to parse package name at version_match: " + name) + + # If name includes no version but is version-less ("--"). + elif versionless_match: + match = re.search("^(?P<stem>.*)--(?P<flavor>[a-z].*)?$", name) + if match: + pkg_spec['stem'] = match.group('stem') + pkg_spec['version_separator'] = '-' + pkg_spec['version'] = None + pkg_spec['flavor_separator'] = '-' + pkg_spec['flavor'] = match.group('flavor') + pkg_spec['style'] = 'versionless' + else: + module.fail_json(msg="unable to parse package name at versionless_match: " + name) + + # If name includes no version, and is not version-less, it is all a stem. + else: + match = re.search("^(?P<stem>.*)$", name) + if match: + pkg_spec['stem'] = match.group('stem') + pkg_spec['version_separator'] = None + pkg_spec['version'] = None + pkg_spec['flavor_separator'] = None + pkg_spec['flavor'] = None + pkg_spec['style'] = 'stem' + else: + module.fail_json(msg="unable to parse package name at else: " + name) + + # If the stem contains an "%" then it needs special treatment. + branch_match = re.search("%", pkg_spec['stem']) + if branch_match: + + branch_release = "6.0" + + if version_match or versionless_match: + module.fail_json(msg="package name using 'branch' syntax also has a version or is version-less: " + name) + if StrictVersion(platform.release()) < StrictVersion(branch_release): + module.fail_json(msg="package name using 'branch' syntax requires at least OpenBSD %s: %s" % (branch_release, name)) + + pkg_spec['style'] = 'branch' + + # Sanity check that there are no trailing dashes in flavor. + # Try to stop strange stuff early so we can be strict later. + if pkg_spec['flavor']: + match = re.search("-$", pkg_spec['flavor']) + if match: + module.fail_json(msg="trailing dash in flavor: " + pkg_spec['flavor']) + +# Function used for figuring out the port path. +def get_package_source_path(name, pkg_spec, module): + pkg_spec['subpackage'] = None + if pkg_spec['stem'] == 'sqlports': + return 'databases/sqlports' + else: + # try for an exact match first + sqlports_db_file = '/usr/local/share/sqlports' + if not os.path.isfile(sqlports_db_file): + module.fail_json(msg="sqlports file '%s' is missing" % sqlports_db_file) + + conn = sqlite3.connect(sqlports_db_file) + first_part_of_query = 'SELECT fullpkgpath, fullpkgname FROM ports WHERE fullpkgname' + query = first_part_of_query + ' = ?' + module.debug("package_package_source_path(): exact query: %s" % query) + cursor = conn.execute(query, (name,)) + results = cursor.fetchall() + + # next, try for a fuzzier match + if len(results) < 1: + looking_for = pkg_spec['stem'] + (pkg_spec['version_separator'] or '-') + (pkg_spec['version'] or '%') + query = first_part_of_query + ' LIKE ?' + if pkg_spec['flavor']: + looking_for += pkg_spec['flavor_separator'] + pkg_spec['flavor'] + module.debug("package_package_source_path(): fuzzy flavor query: %s" % query) + cursor = conn.execute(query, (looking_for,)) + elif pkg_spec['style'] == 'versionless': + query += ' AND fullpkgname NOT LIKE ?' + module.debug("package_package_source_path(): fuzzy versionless query: %s" % query) + cursor = conn.execute(query, (looking_for, "%s-%%" % looking_for,)) + else: + module.debug("package_package_source_path(): fuzzy query: %s" % query) + cursor = conn.execute(query, (looking_for,)) + results = cursor.fetchall() + + # error if we don't find exactly 1 match + conn.close() + if len(results) < 1: + module.fail_json(msg="could not find a port by the name '%s'" % name) + if len(results) > 1: + matches = map(lambda x:x[1], results) + module.fail_json(msg="too many matches, unsure which to build: %s" % ' OR '.join(matches)) + + # there's exactly 1 match, so figure out the subpackage, if any, then return + fullpkgpath = results[0][0] + parts = fullpkgpath.split(',') + if len(parts) > 1 and parts[1][0] == '-': + pkg_spec['subpackage'] = parts[1] + return parts[0] + +# Function used for upgrading all installed packages. +def upgrade_packages(module): + if module.check_mode: + upgrade_cmd = 'pkg_add -Imnu' + else: + upgrade_cmd = 'pkg_add -Imu' + + # Attempt to upgrade all packages. + rc, stdout, stderr = execute_command("%s" % upgrade_cmd, module) + + # Try to find any occurance of a package changing version like: + # "bzip2-1.0.6->1.0.6p0: ok". + match = re.search("\W\w.+->.+: ok\W", stdout) + if match: + if module.check_mode: + module.exit_json(changed=True) + + changed=True + + else: + changed=False + + # It seems we can not trust the return value, so depend on the presence of + # stderr to know if something failed. + if stderr: + rc = 1 + else: + rc = 0 + + return (rc, stdout, stderr, changed) + +# =========================================== +# Main control flow. + +def main(): + module = AnsibleModule( + argument_spec = dict( + name = dict(required=True), + state = dict(required=True, choices=['absent', 'installed', 'latest', 'present', 'removed']), + build = dict(default='no', type='bool'), + ports_dir = dict(default='/usr/ports'), + ), + supports_check_mode = True + ) + + name = module.params['name'] + state = module.params['state'] + build = module.params['build'] + ports_dir = module.params['ports_dir'] + + rc = 0 + stdout = '' + stderr = '' + result = {} + result['name'] = name + result['state'] = state + result['build'] = build + + if build is True: + if not os.path.isdir(ports_dir): + module.fail_json(msg="the ports source directory %s does not exist" % (ports_dir)) + + # build sqlports if its not installed yet + pkg_spec = {} + parse_package_name('sqlports', pkg_spec, module) + installed_state = get_package_state('sqlports', pkg_spec, module) + if not installed_state: + module.debug("main(): installing 'sqlports' because build=%s" % module.params['build']) + package_present('sqlports', installed_state, pkg_spec, module) + + if name == '*': + if state != 'latest': + module.fail_json(msg="the package name '*' is only valid when using state=latest") + else: + # Perform an upgrade of all installed packages. + (rc, stdout, stderr, changed) = upgrade_packages(module) + else: + # Parse package name and put results in the pkg_spec dictionary. + pkg_spec = {} + parse_package_name(name, pkg_spec, module) + + # Not sure how the branch syntax is supposed to play together + # with build mode. Disable it for now. + if pkg_spec['style'] == 'branch' and module.params['build'] is True: + module.fail_json(msg="the combination of 'branch' syntax and build=%s is not supported: %s" % (module.params['build'], name)) + + # Get package state. + installed_state = get_package_state(name, pkg_spec, module) + + # Perform requested action. + if state in ['installed', 'present']: + (rc, stdout, stderr, changed) = package_present(name, installed_state, pkg_spec, module) + elif state in ['absent', 'removed']: + (rc, stdout, stderr, changed) = package_absent(name, installed_state, module) + elif state == 'latest': + (rc, stdout, stderr, changed) = package_latest(name, installed_state, pkg_spec, module) + + if rc != 0: + if stderr: + module.fail_json(msg=stderr) + else: + module.fail_json(msg=stdout) + + result['changed'] = changed + + module.exit_json(**result) + +# Import module snippets. +from ansible.module_utils.basic import * +main() |