mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-10-09 01:44:03 -07:00
* openbsd_pkg: Use correct part of name in match. Previously this part of the code could assume that the name was a stem with nothing else attached (like "autoconf"). With the introduction of the branch syntax ("autoconf%2.13") this is no longer true. Check if the package name was identified as using a "branch" style name, and base the match on the leading part of the name if that is the case. While here remove unnecessary "pass" and tidy up debug log message. Problem reported by @jasperla. * openbsd_pkg: Add missing "." in comment.
529 lines
20 KiB
Python
529 lines
20 KiB
Python
#!/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.
|
|
if pkg_spec['style'] == 'branch':
|
|
match = re.search("\W%s-[^:]+: ok\W" % pkg_spec['pkgname'], stdout)
|
|
else:
|
|
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 the package")
|
|
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'
|
|
|
|
# Key names from description in pkg_add(1).
|
|
pkg_spec['pkgname'] = pkg_spec['stem'].split('%')[0]
|
|
pkg_spec['branch'] = pkg_spec['stem'].split('%')[1]
|
|
|
|
# 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()
|