refactor zypper module

* refactor zypper module

Cleanup:
* remove mention of old_zypper (no longer supported)
  * requirement goes up to zypper 1.0, SLES 11.0, openSUSE 11.1
  * allows to use newer features (xml output)
  * already done for zypper_repository
* use zypper instead of rpm to get old version information, based on work by @jasonmader
* don't use rpm, zypper can do everything itself
* run zypper only twice, first to determine current state, then to apply changes

New features:
* determine change by parsing zypper xmlout
* determine failure by checking return code
* allow simulataneous installation/removal of packages (using '-' and '+' prefix)
  * allows to swap out alternatives without removing packages depending
    on them
* implement checkmode, using zypper --dry-run
* implement diffmode
* implement 'name=* state=latest' and 'name=* state=latest type=patch'
* add force parameter, handed to zypper to allow downgrade or change of vendor/architecture

Fixes/Replaces:
* fixes #1627, give changed=False on installed patches
* fixes #2094, handling URLs for packages
* fixes #1461, fixes #546, allow state=latest name='*'
* fixes #299, changed=False on second install, actually this was fixed earlier, but it is explicitly tested now
* fixes #1824, add type=application
* fixes #1256, install rpm from path, this is done by passing URLs and paths directly to zypper

* fix typo in package_update_all

* minor fixes

* remove commented code block
* bump version added to 2.2
* deal with zypper return codes 103 and 106
reviewable/pr18780/r1
Robin Roth 9 years ago committed by René Moser
parent f2383fe0ac
commit bb68df525c

@ -26,7 +26,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>. # along with Ansible. If not, see <http://www.gnu.org/licenses/>.
import re from xml.dom.minidom import parseString as parseXML
DOCUMENTATION = ''' DOCUMENTATION = '''
--- ---
@ -34,6 +34,8 @@ module: zypper
author: author:
- "Patrick Callahan (@dirtyharrycallahan)" - "Patrick Callahan (@dirtyharrycallahan)"
- "Alexander Gubin (@alxgu)" - "Alexander Gubin (@alxgu)"
- "Thomas O'Donnell (@andytom)"
- "Robin Roth (@robinro)"
version_added: "1.2" version_added: "1.2"
short_description: Manage packages on SUSE and openSUSE short_description: Manage packages on SUSE and openSUSE
description: description:
@ -41,7 +43,7 @@ description:
options: options:
name: name:
description: description:
- package name or package specifier with version C(name) or C(name-1.0). You can also pass a url or a local path to a rpm file. - package name or package specifier with version C(name) or C(name-1.0). You can also pass a url or a local path to a rpm file. When using state=latest, this can be '*', which updates all installed packages.
required: true required: true
aliases: [ 'pkg' ] aliases: [ 'pkg' ]
state: state:
@ -56,7 +58,7 @@ options:
description: description:
- The type of package to be operated on. - The type of package to be operated on.
required: false required: false
choices: [ package, patch, pattern, product, srcpackage ] choices: [ package, patch, pattern, product, srcpackage, application ]
default: "package" default: "package"
version_added: "2.0" version_added: "2.0"
disable_gpg_check: disable_gpg_check:
@ -67,7 +69,6 @@ options:
required: false required: false
default: "no" default: "no"
choices: [ "yes", "no" ] choices: [ "yes", "no" ]
aliases: []
disable_recommends: disable_recommends:
version_added: "1.8" version_added: "1.8"
description: description:
@ -75,10 +76,18 @@ options:
required: false required: false
default: "yes" default: "yes"
choices: [ "yes", "no" ] choices: [ "yes", "no" ]
force:
version_added: "2.2"
description:
- Adds C(--force) option to I(zypper). Allows to downgrade packages and change vendor or architecture.
required: false
default: "no"
choices: [ "yes", "no" ]
notes: []
# informational: requirements for nodes # informational: requirements for nodes
requirements: [ zypper, rpm ] requirements:
- "zypper >= 1.0 # included in openSuSE >= 11.1 or SuSE Linux Enterprise Server/Desktop >= 11.0"
- rpm
''' '''
EXAMPLES = ''' EXAMPLES = '''
@ -88,6 +97,9 @@ EXAMPLES = '''
# Install apache2 with recommended packages # Install apache2 with recommended packages
- zypper: name=apache2 state=present disable_recommends=no - zypper: name=apache2 state=present disable_recommends=no
# Apply a given patch
- zypper: name=openSUSE-2016-128 state=present type=patch
# Remove the "nmap" package # Remove the "nmap" package
- zypper: name=nmap state=absent - zypper: name=nmap state=absent
@ -96,168 +108,222 @@ EXAMPLES = '''
# Install local rpm file # Install local rpm file
- zypper: name=/tmp/fancy-software.rpm state=present - zypper: name=/tmp/fancy-software.rpm state=present
# Update all packages
- zypper: name=* state=latest
# Apply all available patches
- zypper: name=* state=latest type=patch
''' '''
# Function used for getting zypper version
def zypper_version(module):
"""Return (rc, message) tuple"""
cmd = ['/usr/bin/zypper', '-V']
rc, stdout, stderr = module.run_command(cmd, check_rc=False)
if rc == 0:
return rc, stdout
else:
return rc, stderr
# Function used for getting versions of currently installed packages. def get_want_state(m, names, remove=False):
def get_current_version(m, packages): packages_install = []
cmd = ['/bin/rpm', '-q', '--qf', '%{NAME} %{VERSION}-%{RELEASE}\n'] packages_remove = []
cmd.extend(packages) urls = []
for name in names:
if '://' in name or name.endswith('.rpm'):
urls.append(name)
elif name.startswith('-') or name.startswith('~'):
packages_remove.append(name[1:])
elif name.startswith('+'):
packages_install.append(name[1:])
else:
if remove:
packages_remove.append(name)
else:
packages_install.append(name)
return packages_install, packages_remove, urls
rc, stdout, stderr = m.run_command(cmd, check_rc=False)
current_version = {} def get_installed_state(m, packages):
rpmoutput_re = re.compile('^(\S+) (\S+)$') "get installed state of packages"
for stdoutline in stdout.splitlines(): cmd = get_cmd(m, 'search')
match = rpmoutput_re.match(stdoutline) cmd.extend(['--match-exact', '--verbose', '--installed-only'])
if match == None:
return None
package = match.group(1)
version = match.group(2)
current_version[package] = version
for package in packages:
if package not in current_version:
print package + ' was not returned by rpm \n'
return None
return current_version
# Function used to find out if a package is currently installed.
def get_package_state(m, packages):
for i in range(0, len(packages)):
# Check state of a local rpm-file
if ".rpm" in packages[i]:
# Check if rpm file is available
package = packages[i]
if not os.path.isfile(package) and not '://' in package:
stderr = "No Package file matching '%s' found on system" % package
m.fail_json(msg=stderr, rc=1)
# Get packagename from rpm file
cmd = ['/bin/rpm', '--query', '--qf', '%{NAME}', '--package']
cmd.append(package)
rc, stdout, stderr = m.run_command(cmd, check_rc=False)
packages[i] = stdout
cmd = ['/bin/rpm', '--query', '--qf', 'package %{NAME} is installed\n']
cmd.extend(packages) cmd.extend(packages)
return parse_zypper_xml(m, cmd, fail_not_found=False)[0]
def parse_zypper_xml(m, cmd, fail_not_found=True, packages=None):
rc, stdout, stderr = m.run_command(cmd, check_rc=False) rc, stdout, stderr = m.run_command(cmd, check_rc=False)
installed_state = {} dom = parseXML(stdout)
rpmoutput_re = re.compile('^package (\S+) (.*)$') if rc == 104:
for stdoutline in stdout.splitlines(): # exit code 104 is ZYPPER_EXIT_INF_CAP_NOT_FOUND (no packages found)
match = rpmoutput_re.match(stdoutline) if fail_not_found:
if match == None: errmsg = dom.getElementsByTagName('message')[-1].childNodes[0].data
continue m.fail_json(msg=errmsg, rc=rc, stdout=stdout, stderr=stderr, cmd=cmd)
package = match.group(1)
result = match.group(2)
if result == 'is installed':
installed_state[package] = True
else: else:
installed_state[package] = False return {}, rc, stdout, stderr
elif rc in [0, 106, 103]:
return installed_state # zypper exit codes
# 0: success
# Function used to make sure a package is present. # 106: signature verification failed
def package_present(m, name, installed_state, package_type, disable_gpg_check, disable_recommends, old_zypper): # 103: zypper was upgraded, run same command again
packages = [] if packages is None:
for package in name: firstrun = True
if package not in installed_state or installed_state[package] is False: packages = {}
packages.append(package) solvable_list = dom.getElementsByTagName('solvable')
if len(packages) != 0: for solvable in solvable_list:
cmd = ['/usr/bin/zypper', '--non-interactive'] name = solvable.getAttribute('name')
# add global options before zypper command packages[name] = {}
if disable_gpg_check: packages[name]['version'] = solvable.getAttribute('edition')
cmd.append('--no-gpg-checks') packages[name]['oldversion'] = solvable.getAttribute('edition-old')
cmd.extend(['install', '--auto-agree-with-licenses', '-t', package_type]) status = solvable.getAttribute('status')
# add install parameter packages[name]['installed'] = status == "installed"
if disable_recommends and not old_zypper: packages[name]['group'] = solvable.parentNode.nodeName
cmd.append('--no-recommends') if rc == 103 and firstrun:
cmd.extend(packages) # if this was the first run and it failed with 103
rc, stdout, stderr = m.run_command(cmd, check_rc=False) # run zypper again with the same command to complete update
return parse_zypper_xml(m, cmd, fail_not_found=fail_not_found, packages=packages)
return packages, rc, stdout, stderr
m.fail_json(msg='Zypper run command failed with return code %s.'%rc, rc=rc, stdout=stdout, stderr=stderr, cmd=cmd)
def get_cmd(m, subcommand):
"puts together the basic zypper command arguments with those passed to the module"
is_install = subcommand in ['install', 'update', 'patch']
cmd = ['/usr/bin/zypper', '--quiet', '--non-interactive', '--xmlout']
# add global options before zypper command
if is_install and m.params['disable_gpg_check']:
cmd.append('--no-gpg-checks')
if rc == 0: cmd.append(subcommand)
changed=True if subcommand != 'patch':
else: cmd.extend(['--type', m.params['type']])
changed=False if m.check_mode and subcommand != 'search':
cmd.append('--dry-run')
if is_install:
cmd.append('--auto-agree-with-licenses')
if m.params['disable_recommends']:
cmd.append('--no-recommends')
if m.params['force']:
cmd.append('--force')
return cmd
def set_diff(m, retvals, result):
packages = {'installed': [], 'removed': [], 'upgraded': []}
for p in result:
group = result[p]['group']
if group == 'to-upgrade':
versions = ' (' + result[p]['oldversion'] + ' => ' + result[p]['version'] + ')'
packages['upgraded'].append(p + versions)
elif group == 'to-install':
packages['installed'].append(p)
elif group == 'to-remove':
packages['removed'].append(p)
output = ''
for state in packages:
if packages[state]:
output += state + ': ' + ', '.join(packages[state]) + '\n'
if 'diff' not in retvals:
retvals['diff'] = {}
if 'prepared' not in retvals['diff']:
retvals['diff']['prepared'] = output
else: else:
rc = 0 retvals['diff']['prepared'] += '\n' + output
stdout = ''
stderr = ''
changed=False def package_present(m, name, want_latest):
"install and update (if want_latest) the packages in name_install, while removing the packages in name_remove"
return (rc, stdout, stderr, changed) retvals = {'rc': 0, 'stdout': '', 'stderr': '', 'changed': False, 'failed': False}
name_install, name_remove, urls = get_want_state(m, name)
# Function used to make sure a package is the latest available version.
def package_latest(m, name, installed_state, package_type, disable_gpg_check, disable_recommends, old_zypper): if not want_latest:
# for state=present: filter out already installed packages
# first of all, make sure all the packages are installed prerun_state = get_installed_state(m, name_install + name_remove)
(rc, stdout, stderr, changed) = package_present(m, name, installed_state, package_type, disable_gpg_check, disable_recommends, old_zypper) # generate lists of packages to install or remove
name_install = [p for p in name_install if p not in prerun_state]
# return if an error occured while installation name_remove = [p for p in name_remove if p in prerun_state]
# otherwise error messages will be lost and user doesn`t see any error if not name_install and not name_remove and not urls:
if rc: # nothing to install/remove and nothing to update
return (rc, stdout, stderr, changed) return retvals
# zypper install also updates packages
cmd = get_cmd(m, 'install')
cmd.append('--')
cmd.extend(urls)
# allow for + or - prefixes in install/remove lists
# do this in one zypper run to allow for dependency-resolution
# for example "-exim postfix" runs without removing packages depending on mailserver
cmd.extend(name_install)
cmd.extend(['-%s' % p for p in name_remove])
retvals['cmd'] = cmd
result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd)
if retvals['rc'] == 0:
# installed all packages successfully
# checking the output is not straight-forward because zypper rewrites 'capabilities'
# could run get_installed_state and recheck, but this takes time
if result:
retvals['changed'] = True
else:
retvals['failed'] = True
# return retvals
if m._diff:
set_diff(m, retvals, result)
# if we've already made a change, we don't have to check whether a version changed return retvals
if not changed:
pre_upgrade_versions = get_current_version(m, name)
cmd = ['/usr/bin/zypper', '--non-interactive']
if disable_gpg_check: def package_update_all(m, do_patch):
cmd.append('--no-gpg-checks') "run update or patch on all available packages"
retvals = {'rc': 0, 'stdout': '', 'stderr': '', 'changed': False, 'failed': False}
if old_zypper: if do_patch:
cmd.extend(['install', '--auto-agree-with-licenses', '-t', package_type]) cmdname = 'patch'
else:
cmdname = 'update'
cmd = get_cmd(m, cmdname)
retvals['cmd'] = cmd
result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd)
if retvals['rc'] == 0:
if result:
retvals['changed'] = True
else: else:
cmd.extend(['update', '--auto-agree-with-licenses', '-t', package_type]) retvals['failed'] = True
if m._diff:
set_diff(m, retvals, result)
return retvals
cmd.extend(name)
rc, stdout, stderr = m.run_command(cmd, check_rc=False)
# if we've already made a change, we don't have to check whether a version changed def package_absent(m, name):
if not changed: "remove the packages in name"
post_upgrade_versions = get_current_version(m, name) retvals = {'rc': 0, 'stdout': '', 'stderr': '', 'changed': False, 'failed': False}
if pre_upgrade_versions != post_upgrade_versions: # Get package state
changed = True name_install, name_remove, urls = get_want_state(m, name, remove=True)
if name_install:
return (rc, stdout, stderr, changed) m.fail_json(msg="Can not combine '+' prefix with state=remove/absent.")
if urls:
# Function used to make sure a package is not installed. m.fail_json(msg="Can not remove via URL.")
def package_absent(m, name, installed_state, package_type, old_zypper): if m.params['type'] == 'patch':
packages = [] m.fail_json(msg="Can not remove patches.")
for package in name: prerun_state = get_installed_state(m, name_remove)
if package not in installed_state or installed_state[package] is True: name_remove = [p for p in name_remove if p in prerun_state]
packages.append(package) if not name_remove:
if len(packages) != 0: return retvals
cmd = ['/usr/bin/zypper', '--non-interactive', 'remove', '-t', package_type]
cmd.extend(packages) cmd = get_cmd(m, 'remove')
rc, stdout, stderr = m.run_command(cmd) cmd.extend(name_remove)
if rc == 0: retvals['cmd'] = cmd
changed=True result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd)
else: if retvals['rc'] == 0:
changed=False # removed packages successfully
if result:
retvals['changed'] = True
else: else:
rc = 0 retvals['failed'] = True
stdout = '' if m._diff:
stderr = '' set_diff(m, retvals, result)
changed=False
return (rc, stdout, stderr, changed) return retvals
# =========================================== # ===========================================
# Main control flow # Main control flow
@ -267,57 +333,40 @@ def main():
argument_spec = dict( argument_spec = dict(
name = dict(required=True, aliases=['pkg'], type='list'), name = dict(required=True, aliases=['pkg'], type='list'),
state = dict(required=False, default='present', choices=['absent', 'installed', 'latest', 'present', 'removed']), state = dict(required=False, default='present', choices=['absent', 'installed', 'latest', 'present', 'removed']),
type = dict(required=False, default='package', choices=['package', 'patch', 'pattern', 'product', 'srcpackage']), type = dict(required=False, default='package', choices=['package', 'patch', 'pattern', 'product', 'srcpackage', 'application']),
disable_gpg_check = dict(required=False, default='no', type='bool'), disable_gpg_check = dict(required=False, default='no', type='bool'),
disable_recommends = dict(required=False, default='yes', type='bool'), disable_recommends = dict(required=False, default='yes', type='bool'),
force = dict(required=False, default='no', type='bool'),
), ),
supports_check_mode = False supports_check_mode = True
) )
name = module.params['name']
state = module.params['state']
params = module.params # Perform requested action
if name == ['*'] and state == 'latest':
name = params['name'] if module.params['type'] == 'package':
state = params['state'] retvals = package_update_all(module, False)
type_ = params['type'] elif module.params['type'] == 'patch':
disable_gpg_check = params['disable_gpg_check'] retvals = package_update_all(module, True)
disable_recommends = params['disable_recommends']
rc = 0
stdout = ''
stderr = ''
result = {}
result['name'] = name
result['state'] = state
rc, out = zypper_version(module)
match = re.match(r'zypper\s+(\d+)\.(\d+)\.(\d+)', out)
if not match or int(match.group(1)) > 0:
old_zypper = False
else: else:
old_zypper = True if state in ['absent', 'removed']:
retvals = package_absent(module, name)
elif state in ['installed', 'present', 'latest']:
retvals = package_present(module, name, state == 'latest')
# Get package state failed = retvals['failed']
installed_state = get_package_state(module, name) del retvals['failed']
# Perform requested action if failed:
if state in ['installed', 'present']: module.fail_json(msg="Zypper run failed.", **retvals)
(rc, stdout, stderr, changed) = package_present(module, name, installed_state, type_, disable_gpg_check, disable_recommends, old_zypper)
elif state in ['absent', 'removed']:
(rc, stdout, stderr, changed) = package_absent(module, name, installed_state, type_, old_zypper)
elif state == 'latest':
(rc, stdout, stderr, changed) = package_latest(module, name, installed_state, type_, disable_gpg_check, disable_recommends, old_zypper)
if rc != 0:
if stderr:
module.fail_json(msg=stderr, rc=rc)
else:
module.fail_json(msg=stdout, rc=rc)
result['changed'] = changed if not retvals['changed']:
result['rc'] = rc del retvals['stdout']
del retvals['stderr']
module.exit_json(**result) module.exit_json(name=name, state=state, **retvals)
# import module snippets # import module snippets
from ansible.module_utils.basic import * from ansible.module_utils.basic import *

Loading…
Cancel
Save