freeipa/ipaserver/plugins/server.py
2016-06-27 12:59:48 +02:00

875 lines
31 KiB
Python

#
# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
#
import dbus
import dbus.mainloop.glib
import ldap
import time
from ipalib import api, crud, errors, messages
from ipalib import Int, Flag, Str, DNSNameParam
from ipalib.plugable import Registry
from .baseldap import (
LDAPSearch,
LDAPRetrieve,
LDAPDelete,
LDAPObject,
LDAPUpdate,
)
from ipalib.request import context
from ipalib import _, ngettext
from ipalib import output
from ipaplatform import services
from ipapython.dn import DN
from ipapython.dnsutil import DNSName
from ipaserver import topology
from ipaserver.servroles import ENABLED
from ipaserver.install import bindinstance, dnskeysyncinstance
__doc__ = _("""
IPA servers
""") + _("""
Get information about installed IPA servers.
""") + _("""
EXAMPLES:
""") + _("""
Find all servers:
ipa server-find
""") + _("""
Show specific server:
ipa server-show ipa.example.com
""")
register = Registry()
@register()
class server(LDAPObject):
"""
IPA server
"""
container_dn = api.env.container_masters
object_name = _('server')
object_name_plural = _('servers')
object_class = ['top']
possible_objectclasses = ['ipaLocationMember']
search_attributes = ['cn']
default_attributes = [
'cn', 'iparepltopomanagedsuffix', 'ipamindomainlevel',
'ipamaxdomainlevel', 'ipalocation', 'ipaserviceweight'
]
label = _('IPA Servers')
label_singular = _('IPA Server')
attribute_members = {
'iparepltopomanagedsuffix': ['topologysuffix'],
'ipalocation': ['location'],
'role': ['servrole'],
}
relationships = {
'iparepltopomanagedsuffix': ('Managed', '', 'no_'),
'ipalocation': ('IPA', 'in_', 'not_in_'),
'role': ('Enabled', '', 'no_'),
}
permission_filter_objectclasses = ['ipaConfigObject']
managed_permissions = {
'System: Read Locations of IPA Servers': {
'ipapermright': {'read', 'search', 'compare'},
'ipapermdefaultattr': {
'objectclass', 'cn', 'ipalocation', 'ipaserviceweight',
},
'default_privileges': {'DNS Administrators'},
},
'System: Read Status of Services on IPA Servers': {
'ipapermright': {'read', 'search', 'compare'},
'ipapermdefaultattr': {'objectclass', 'cn', 'ipaconfigstring'},
'default_privileges': {'DNS Administrators'},
}
}
takes_params = (
Str(
'cn',
cli_name='name',
primary_key=True,
label=_('Server name'),
doc=_('IPA server hostname'),
),
Str(
'iparepltopomanagedsuffix*',
flags={'no_create', 'no_update', 'no_search'},
),
Str(
'iparepltopomanagedsuffix_topologysuffix*',
label=_('Managed suffixes'),
flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'},
),
Int(
'ipamindomainlevel',
cli_name='minlevel',
label=_('Min domain level'),
doc=_('Minimum domain level'),
flags={'no_create', 'no_update'},
),
Int(
'ipamaxdomainlevel',
cli_name='maxlevel',
label=_('Max domain level'),
doc=_('Maximum domain level'),
flags={'no_create', 'no_update'},
),
DNSNameParam(
'ipalocation_location?',
cli_name='location',
label=_('Location'),
doc=_('Server location'),
only_relative=True,
flags={'no_search'},
),
Int(
'ipaserviceweight?',
cli_name='service_weight',
label=_('Service weight'),
doc=_('Weight for server services'),
minvalue=0,
maxvalue=65535,
flags={'no_search'},
),
Str(
'service_relative_weight',
label=_('Service relative weight'),
doc=_('Relative weight for server services (counts per location)'),
flags={'virtual_attribute','no_create', 'no_update', 'no_search'},
),
Str(
'enabled_role_servrole*',
label=_('Enabled server roles'),
doc=_('List of enabled roles'),
flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'}
),
)
def _get_suffixes(self):
suffixes = self.api.Command.topologysuffix_find(
all=True, raw=True,
)['result']
suffixes = [(s['iparepltopoconfroot'][0], s['dn']) for s in suffixes]
return suffixes
def _apply_suffixes(self, entry, suffixes):
# change suffix DNs to topologysuffix entry DNs
# this fixes LDAPObject.convert_attribute_members() for suffixes
suffixes = dict(suffixes)
if 'iparepltopomanagedsuffix' in entry:
entry['iparepltopomanagedsuffix'] = [
suffixes.get(m, m) for m in entry['iparepltopomanagedsuffix']
]
def normalize_location(self, kw, **options):
"""
Return the DN of location
"""
if 'ipalocation_location' in kw:
location = kw.pop('ipalocation_location')
kw['ipalocation'] = (
[self.api.Object.location.get_dn(location)]
if location is not None else location
)
def convert_location(self, entry_attrs, **options):
"""
Return a location name from DN
"""
if options.get('raw'):
return
converted_locations = [
DNSName(location_dn['idnsname']) for
location_dn in entry_attrs.pop('ipalocation', [])
]
if converted_locations:
entry_attrs['ipalocation_location'] = converted_locations
def get_enabled_roles(self, entry_attrs, **options):
if not options.get('all', False) and options.get('no_members', False):
return
if options.get('raw', False):
return
enabled_roles = self.api.Command.server_role_find(
server_server=entry_attrs['cn'][0], status=ENABLED)['result']
enabled_role_names = [r[u'role_servrole'] for r in enabled_roles]
entry_attrs['enabled_role_servrole'] = enabled_role_names
@register()
class server_mod(LDAPUpdate):
__doc__ = _('Modify information about an IPA server.')
msg_summary = _('Modified IPA server "%(value)s"')
def args_options_2_entry(self, *args, **options):
kw = super(server_mod, self).args_options_2_entry(
*args, **options)
self.obj.normalize_location(kw, **options)
return kw
def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
assert isinstance(dn, DN)
if entry_attrs.get('ipalocation'):
if not ldap.entry_exists(entry_attrs['ipalocation'][0]):
self.api.Object.location.handle_not_found(
options['ipalocation_location'])
if 'ipalocation' in entry_attrs or 'ipaserviceweight' in entry_attrs:
server_entry = ldap.get_entry(dn, ['objectclass'])
# we need to extend object with ipaLocationMember objectclass
entry_attrs['objectclass'] = (
server_entry['objectclass'] + ['ipalocationmember']
)
return dn
def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
assert isinstance(dn, DN)
self.obj.get_enabled_roles(entry_attrs)
if 'ipalocation_location' in options:
ipalocation = entry_attrs.get('ipalocation')
if ipalocation:
ipalocation = ipalocation[0]['idnsname']
else:
ipalocation = u''
try:
self.api.Command.dnsserver_mod(
keys[0],
setattr=[
u'idnsSubstitutionVariable;ipalocation={loc}'.format(
loc=ipalocation)
]
)
except errors.EmptyModlist:
pass
except errors.NotFound:
# server is not DNS server
pass
if 'ipalocation_location' or 'ipaserviceweight' in options:
self.add_message(messages.ServiceRestartRequired(
service=services.service('named').systemd_name,
server=keys[0], ))
result = self.api.Command.dns_update_system_records()
if not result.get('value'):
self.add_message(messages.AutomaticDNSRecordsUpdateFailed())
self.obj.convert_location(entry_attrs, **options)
ipalocation = entry_attrs.get('ipalocation_location', [None])[0]
if ipalocation:
servers_in_loc = self.api.Command.server_find(
in_location=ipalocation, no_members=False)['result']
dns_server_in_loc = False
for server in servers_in_loc:
if 'DNS server' in server.get('enabled_role_servrole', ()):
dns_server_in_loc = True
break
if not dns_server_in_loc:
self.add_message(messages.LocationWithoutDNSServer(
location=ipalocation
))
return dn
@register()
class server_find(LDAPSearch):
__doc__ = _('Search for IPA servers.')
msg_summary = ngettext(
'%(count)d IPA server matched',
'%(count)d IPA servers matched', 0
)
member_attributes = ['iparepltopomanagedsuffix', 'ipalocation', 'role']
def args_options_2_entry(self, *args, **options):
kw = super(server_find, self).args_options_2_entry(
*args, **options)
self.obj.normalize_location(kw, **options)
return kw
def get_options(self):
for option in super(server_find, self).get_options():
if option.name == 'topologysuffix':
option = option.clone(cli_name='topologysuffixes')
elif option.name == 'no_topologysuffix':
option = option.clone(cli_name='no_topologysuffixes')
# we do not want to test negative membership for roles
elif option.name == 'no_servrole':
continue
yield option
def get_member_filter(self, ldap, **options):
options.pop('topologysuffix', None)
options.pop('no_topologysuffix', None)
options.pop('servrole', None)
return super(server_find, self).get_member_filter(
ldap, **options)
def _get_enabled_servrole_filter(self, ldap, servroles):
"""
return a filter matching any master which has all the specified roles
enabled.
"""
def _get_masters_with_enabled_servrole(role):
role_status = self.api.Command.server_role_find(
server_server=None,
role_servrole=role,
status=ENABLED)['result']
return set(
r[u'server_server'] for r in role_status)
enabled_masters = _get_masters_with_enabled_servrole(
servroles[0])
for role in servroles[1:]:
enabled_masters.intersection_update(
_get_masters_with_enabled_servrole(role)
)
if not enabled_masters:
return '(!(objectclass=*))'
return ldap.make_filter_from_attr(
'cn',
list(enabled_masters),
rules=ldap.MATCH_ANY
)
def pre_callback(self, ldap, filters, attrs_list, base_dn, scope,
*args, **options):
included = options.get('topologysuffix')
excluded = options.get('no_topologysuffix')
if included or excluded:
topologysuffix = self.api.Object.topologysuffix
suffixes = self.obj._get_suffixes()
suffixes = {s[1]: s[0] for s in suffixes}
if included:
included = [topologysuffix.get_dn(pk) for pk in included]
try:
included = [suffixes[dn] for dn in included]
except KeyError:
# force empty result
filter = '(!(objectclass=*))'
else:
filter = ldap.make_filter_from_attr(
'iparepltopomanagedsuffix', included, ldap.MATCH_ALL
)
filters = ldap.combine_filters(
(filters, filter), ldap.MATCH_ALL
)
if excluded:
excluded = [topologysuffix.get_dn(pk) for pk in excluded]
excluded = [suffixes[dn] for dn in excluded if dn in suffixes]
filter = ldap.make_filter_from_attr(
'iparepltopomanagedsuffix', excluded, ldap.MATCH_NONE
)
filters = ldap.combine_filters(
(filters, filter), ldap.MATCH_ALL
)
if options.get('servrole', []):
servrole_filter = self._get_enabled_servrole_filter(
ldap, options['servrole'])
filters = ldap.combine_filters(
(filters, servrole_filter), ldap.MATCH_ALL)
return (filters, base_dn, scope)
def post_callback(self, ldap, entries, truncated, *args, **options):
if not options.get('raw', False):
suffixes = self.obj._get_suffixes()
for entry in entries:
self.obj._apply_suffixes(entry, suffixes)
for entry in entries:
self.obj.convert_location(entry, **options)
self.obj.get_enabled_roles(entry, **options)
return truncated
@register()
class server_show(LDAPRetrieve):
__doc__ = _('Show IPA server.')
def post_callback(self, ldap, dn, entry, *keys, **options):
if not options.get('raw', False):
suffixes = self.obj._get_suffixes()
self.obj._apply_suffixes(entry, suffixes)
self.obj.convert_location(entry, **options)
self.obj.get_enabled_roles(entry, **options)
return dn
@register()
class server_del(LDAPDelete):
__doc__ = _('Delete IPA server.')
msg_summary = _('Deleted IPA server "%(value)s"')
takes_options = LDAPDelete.takes_options + (
Flag(
'ignore_topology_disconnect?',
label=_('Ignore topology errors'),
doc=_('Ignore topology connectivity problems after removal'),
default=False,
),
Flag(
'ignore_last_of_role?',
label=_('Ignore check for last remaining CA or DNS server'),
doc=_('Skip a check whether the last CA master or DNS server is '
'removed'),
default=False,
),
Flag(
'force?',
label=_('Force server removal'),
doc=_('Force server removal even if it does not exist'),
default=False,
),
)
def _ensure_last_of_role(self, hostname, ignore_last_of_role=False):
"""
1. When deleting server, check if there will be at least one remaining
DNS and CA server.
2. Pick CA renewal master
"""
def handler(msg, ignore_last_of_role):
if ignore_last_of_role:
self.add_message(
messages.ServerRemovalWarning(
message=msg
)
)
else:
raise errors.ServerRemovalError(reason=_(msg))
ipa_config = self.api.Command.config_show()['result']
ipa_masters = ipa_config['ipa_master_server']
# skip these checks if the last master is being removed
if ipa_masters == [hostname]:
return
if self.api.Command.dns_is_enabled()['result']:
dns_config = self.api.Command.dnsconfig_show()['result']
dns_servers = dns_config.get('dns_server_server', [])
dnssec_keymaster = dns_config.get('dnssec_key_master_server', [])
if dnssec_keymaster == hostname:
handler(
_("Replica is active DNSSEC key master. Uninstall "
"could break your DNS system. Please disable or "
"replace DNSSEC key master first."), ignore_last_of_role)
if dns_servers == [hostname]:
handler(
_("Deleting this server will leave your installation "
"without a DNS."), ignore_last_of_role)
if self.api.Command.ca_is_enabled()['result']:
ca_servers = ipa_config.get('ca_server_server', [])
ca_renewal_master = ipa_config.get(
'ca_renewal_master_server', [])
if ca_servers == [hostname]:
raise errors.ServerRemovalError(
reason=_("Deleting this server is not allowed as it would "
"leave your installation without a CA."))
if ca_renewal_master == hostname:
other_cas = [ca for ca in ca_servers if ca != hostname]
# if this is the last CA there is no other server to become
# renewal master
if not other_cas:
return
self.api.Command.config_mod(
ca_renewal_master_server=other_cas[0])
if ignore_last_of_role:
self.add_message(
messages.ServerRemovalWarning(
message=_("Ignoring these warnings and proceeding with "
"removal")))
def _check_topology_connectivity(self, topology_connectivity, master_cn):
try:
topology_connectivity.check_current_state()
except ValueError as e:
raise errors.ServerRemovalError(reason=e)
try:
topology_connectivity.check_state_after_removal(master_cn)
except ValueError as e:
raise errors.ServerRemovalError(reason=e)
def _remove_server_principal_references(self, master):
"""
This method removes information about the replica in parts
of the shared tree that expose it, so clients stop trying to
use this replica.
"""
conn = self.Backend.ldap2
env = self.api.env
master_principal = "{}@{}".format(master, env.realm)
# remove replica memberPrincipal from s4u2proxy configuration
s4u2proxy_subtree = DN(env.container_s4u2proxy,
env.basedn)
dn1 = DN(('cn', 'ipa-http-delegation'), s4u2proxy_subtree)
member_principal1 = "HTTP/{}".format(master_principal)
dn2 = DN(('cn', 'ipa-ldap-delegation-targets'), s4u2proxy_subtree)
member_principal2 = "ldap/{}".format(master_principal)
dn3 = DN(('cn', 'ipa-cifs-delegation-targets'), s4u2proxy_subtree)
member_principal3 = "cifs/{}".format(master_principal)
for (dn, member_principal) in ((dn1, member_principal1),
(dn2, member_principal2),
(dn3, member_principal3)):
try:
mod = [(ldap.MOD_DELETE, 'memberPrincipal', member_principal)]
conn.conn.modify_s(str(dn), mod)
except (ldap.NO_SUCH_OBJECT, ldap.NO_SUCH_ATTRIBUTE):
self.log.debug(
"Replica (%s) memberPrincipal (%s) not found in %s" %
(master, member_principal, dn))
except Exception as e:
self.add_message(
messages.ServerRemovalWarning(
message=_("Failed to clean memberPrincipal "
"%(principal)s from s4u2proxy entry %(dn)s: "
"%(err)s") % dict(
principal=member_principal,
dn=dn, err=e)))
try:
etc_basedn = DN(('cn', 'etc'), env.basedn)
filter = '(dnaHostname=%s)' % master
entries = conn.get_entries(
etc_basedn, ldap.SCOPE_SUBTREE, filter=filter)
if len(entries) != 0:
for entry in entries:
conn.delete_entry(entry)
except errors.NotFound:
pass
except Exception as e:
self.add_message(
messages.ServerRemovalWarning(
message=_(
"Failed to clean up DNA hostname entries for "
"%(master)s: %(err)s") % dict(master=master, err=e)))
try:
dn = DN(('cn', 'default'), ('ou', 'profile'), env.basedn)
ret = conn.get_entry(dn)
srvlist = ret.single_value.get('defaultServerList', '')
srvlist = srvlist[0].split()
if master in srvlist:
srvlist.remove(master)
attr = ' '.join(srvlist)
mod = [(ldap.MOD_REPLACE, 'defaultServerList', attr)]
conn.conn.modify_s(str(dn), mod)
except (errors.NotFound, ldap.NO_SUCH_ATTRIBUTE,
ldap.TYPE_OR_VALUE_EXISTS):
pass
except Exception as e:
self.add_message(
messages.ServerRemovalWarning(
message=_("Failed to remove server %(master)s from server "
"list: %(err)s") % dict(master=master, err=e)))
def _remove_server_host_services(self, ldap, master):
"""
delete server kerberos key and all its svc principals
"""
try:
entries = ldap.get_entries(
self.api.env.basedn, ldap.SCOPE_SUBTREE,
filter='(krbprincipalname=*/{}@{})'.format(
master, self.api.env.realm))
if entries:
entries.sort(key=lambda x: len(x.dn), reverse=True)
for entry in entries:
ldap.delete_entry(entry)
except errors.NotFound:
pass
except Exception as e:
self.add_message(
messages.ServerRemovalWarning(
message=_("Failed to cleanup server principals/keys: "
"%(err)s") % dict(err=e)))
def _cleanup_server_dns_records(self, hostname, **options):
if not self.api.Command.dns_is_enabled(
**options):
return
try:
bindinstance.remove_master_dns_records(
hostname, self.api.env.realm)
dnskeysyncinstance.remove_replica_public_keys(hostname)
except Exception as e:
self.add_message(
messages.ServerRemovalWarning(
message=_(
"Failed to cleanup %(hostname)s DNS entries: "
"%(err)s") % dict(hostname=hostname, err=e)))
self.add_message(
messages.ServerRemovalWarning(
message=_("You may need to manually remove them from the "
"tree")))
def pre_callback(self, ldap, dn, *keys, **options):
pkey = self.obj.get_primary_key_from_dn(dn)
if options.get('force', False):
self.add_message(
messages.ServerRemovalWarning(
message=_("Forcing removal of %(hostname)s") % dict(
hostname=pkey)))
# check the topology errors before and after removal
self.context.topology_connectivity = topology.TopologyConnectivity(
self.api)
if options.get('ignore_topology_disconnect', False):
self.add_message(
messages.ServerRemovalWarning(
message=_("Ignoring topology connectivity errors.")))
else:
self._check_topology_connectivity(
self.context.topology_connectivity, pkey)
# ensure that we are not removing last CA/DNS server, DNSSec master and
# CA renewal master
self._ensure_last_of_role(
pkey, ignore_last_of_role=options.get('ignore_last_of_role', False)
)
# remove the references to master's ldap/http principals
self._remove_server_principal_references(pkey)
# finally destroy all Kerberos principals
self._remove_server_host_services(ldap, pkey)
# try to clean up the leftover DNS entries
self._cleanup_server_dns_records(pkey)
return dn
def exc_callback(self, keys, options, exc, call_func, *call_args,
**call_kwargs):
if (options.get('force', False) and isinstance(exc, errors.NotFound)
and call_func.__name__ == 'delete_entry'):
self.add_message(
message=messages.ServerRemovalWarning(
message=_("Server has already been deleted")))
return
raise exc
def _check_deleted_segments(self, hostname, topology_connectivity,
starting_host):
def wait_for_segment_removal(hostname, master_cns, suffix_name,
orig_errors, new_errors):
i = 0
while True:
left = self.api.Command.topologysegment_find(
suffix_name,
iparepltoposegmentleftnode=hostname,
sizelimit=0
)['result']
right = self.api.Command.topologysegment_find(
suffix_name,
iparepltoposegmentrightnode=hostname,
sizelimit=0
)['result']
# Relax check if topology was or is disconnected. Disconnected
# topology can contain segments with already deleted servers
# Check only if segments of servers, which can contact this
# server, and the deleted server were removed.
# This code should handle a case where there was a topology
# with a central node(B): A <-> B <-> C, where A is current
# server. After removal of B, topology will be disconnected and
# removal of segment B <-> C won't be replicated back to server
# A, therefore presence of the segment has to be ignored.
if orig_errors or new_errors:
# use errors after deletion because we don't care if some
# server can't contact the deleted one
cant_contact_me = [e[0] for e in new_errors
if starting_host in e[2]]
can_contact_me = set(master_cns) - set(cant_contact_me)
left = [
s for s in left if s['iparepltoposegmentrightnode'][0]
in can_contact_me
]
right = [
s for s in right if s['iparepltoposegmentleftnode'][0]
in can_contact_me
]
if not left and not right:
self.add_message(
messages.ServerRemovalInfo(
message=_("Agreements deleted")
))
return
time.sleep(2)
if i == 2: # taking too long, something is wrong, report
self.log.info(
"Waiting for removal of replication agreements")
if i > 90:
self.log.info("Taking too long, skipping")
self.log.info("Following segments were not deleted:")
self.add_message(messages.ServerRemovalWarning(
message=_("Following segments were not deleted:")))
for s in left:
self.add_message(messages.ServerRemovalWarning(
message=u" %s" % s['cn'][0]))
for s in right:
self.add_message(messages.ServerRemovalWarning(
message=u" %s" % s['cn'][0]))
return
i += 1
topology_graphs = topology_connectivity.graphs
orig_errors = topology_connectivity.errors
new_errors = topology_connectivity.errors_after_master_removal(
hostname
)
for suffix_name in topology_graphs:
suffix_members = topology_graphs[suffix_name].vertices
if hostname not in suffix_members:
# If the server was already deleted, we can expect that all
# removals had been done in previous run and dangling segments
# were not deleted.
self.log.info(
"Skipping replication agreement deletion check for "
"suffix '{0}'".format(suffix_name))
continue
self.log.info(
"Checking for deleted segments in suffix '{0}'".format(
suffix_name))
wait_for_segment_removal(
hostname,
list(suffix_members),
suffix_name,
orig_errors[suffix_name],
new_errors[suffix_name])
def post_callback(self, ldap, dn, *keys, **options):
# there is no point in checking deleted segment on local host
# we should do this only when removing other masters
if self.api.env.host != keys[-1]:
self._check_deleted_segments(
keys[-1], self.context.topology_connectivity,
self.api.env.host)
return super(server_del, self).post_callback(
ldap, dn, *keys, **options)
@register()
class server_conncheck(crud.PKQuery):
__doc__ = _("Check connection to remote IPA server.")
NO_CLI = True
takes_args = (
Str(
'remote_cn',
cli_name='remote_name',
label=_('Remote server name'),
doc=_('Remote IPA server hostname'),
),
)
has_output = output.standard_value
def execute(self, *keys, **options):
# the server must be the local host
if keys[-2] != api.env.host:
raise errors.ValidationError(
name='cn', error=_("must be \"%s\"") % api.env.host)
# the server entry must exist
try:
self.obj.get_dn_if_exists(*keys[:-1])
except errors.NotFound:
self.obj.handle_not_found(keys[-2])
# the user must have the Replication Administrators privilege
privilege = u'Replication Administrators'
privilege_dn = self.api.Object.privilege.get_dn(privilege)
ldap = self.obj.backend
filter = ldap.make_filter({
'krbprincipalname': context.principal, # pylint: disable=no-member
'memberof': privilege_dn},
rules=ldap.MATCH_ALL)
try:
ldap.find_entries(base_dn=self.api.env.basedn, filter=filter)
except errors.NotFound:
raise errors.ACIError(
info=_("not allowed to perform server connection check"))
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
obj = bus.get_object('org.freeipa.server', '/',
follow_name_owner_changes=True)
server = dbus.Interface(obj, 'org.freeipa.server')
ret, stdout, stderr = server.conncheck(keys[-1])
result = dict(
result=(ret == 0),
value=keys[-2],
)
for line in stdout.splitlines():
messages.add_message(options['version'],
result,
messages.ExternalCommandOutput(line=line))
return result