Finish implementation/tests of job template callback.

This commit is contained in:
Chris Church
2013-07-11 22:26:15 -04:00
parent a65d7c09a3
commit d09d94bca2
4 changed files with 283 additions and 51 deletions

View File

@@ -123,6 +123,10 @@ class CustomRbac(permissions.BasePermission):
return self.has_permission(request, view, obj) return self.has_permission(request, view, obj)
class JobTemplateCallbackPermission(CustomRbac): class JobTemplateCallbackPermission(CustomRbac):
'''
Permission check used by job template callback view for requests from
empheral hosts.
'''
def has_permission(self, request, view, obj=None): def has_permission(self, request, view, obj=None):
# If another authentication method was used and it's not a POST, return # If another authentication method was used and it's not a POST, return
@@ -135,17 +139,20 @@ class JobTemplateCallbackPermission(CustomRbac):
# active in order to proceed. # active in order to proceed.
host_config_key = request.DATA.get('host_config_key', '') host_config_key = request.DATA.get('host_config_key', '')
if request.method.lower() != 'post': if request.method.lower() != 'post':
return False raise PermissionDenied()
elif not host_config_key: elif not host_config_key:
return False raise PermissionDenied()
elif obj and not obj.active: elif obj and not obj.active:
return False raise PermissionDenied()
elif obj and obj.host_config_key != host_config_key: elif obj and obj.host_config_key != host_config_key:
return False raise PermissionDenied()
else: else:
return True return True
class JobTaskPermission(CustomRbac): class JobTaskPermission(CustomRbac):
'''
Permission checks used for API callbacks from running a task.
'''
def has_permission(self, request, view, obj=None): def has_permission(self, request, view, obj=None):
# If another authentication method was used other than the one for job # If another authentication method was used other than the one for job

View File

@@ -153,7 +153,7 @@ class BaseTestMixin(object):
client_kwargs = {} client_kwargs = {}
if accept: if accept:
client_kwargs['HTTP_ACCEPT'] = accept client_kwargs['HTTP_ACCEPT'] = accept
if remote_addr: if remote_addr is not None:
client_kwargs['REMOTE_ADDR'] = remote_addr client_kwargs['REMOTE_ADDR'] = remote_addr
client = Client(**client_kwargs) client = Client(**client_kwargs)
auth = auth or self._current_auth auth = auth or self._current_auth

View File

@@ -360,11 +360,11 @@ class BaseJobTestMixin(BaseTestMixin):
project=self.proj_test, project=self.proj_test,
playbook=self.proj_test.playbooks[0], playbook=self.proj_test.playbooks[0],
host_config_key=uuid.uuid4().hex, host_config_key=uuid.uuid4().hex,
credential=self.cred_eve,
created_by=self.user_sue, created_by=self.user_sue,
) )
self.job_sup_run = self.jt_sup_run.create_job( self.job_sup_run = self.jt_sup_run.create_job(
created_by=self.user_sue, created_by=self.user_sue,
credential=self.cred_eve,
) )
# Operations has job templates to check/run the prod project onto # Operations has job templates to check/run the prod project onto
@@ -1016,7 +1016,7 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
'''Return test IP address(es) for given test hostname.''' '''Return test IP address(es) for given test hostname.'''
ips = [] ips = []
try: try:
h = Host.objects.get(name=host) h = Host.objects.exclude(name__endswith='-alias').get(name=host)
# Primary IP for host (both forward/reverse lookups work). # Primary IP for host (both forward/reverse lookups work).
val = self.atoh('127.10.0.0') + h.pk val = self.atoh('127.10.0.0') + h.pk
ips.append(self.htoa(val)) ips.append(self.htoa(val))
@@ -1028,6 +1028,10 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
if h.pk % 3 == 0: if h.pk % 3 == 0:
val = self.atoh('127.30.0.0') + h.pk val = self.atoh('127.30.0.0') + h.pk
ips.append(self.htoa(val)) ips.append(self.htoa(val))
# Additional IP for host (neither forward/reverse lookups work).
if h.pk % 3 == 1:
val = self.atoh('127.40.0.0') + h.pk
ips.append(self.htoa(val))
except Host.DoesNotExist: except Host.DoesNotExist:
pass pass
return ips return ips
@@ -1046,15 +1050,13 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
all_ips = set() all_ips = set()
for host in Host.objects.all(): for host in Host.objects.all():
ips = self.get_test_ips_for_host(host.name) ips = self.get_test_ips_for_host(host.name)
#print host, ips
self.assertTrue(ips) self.assertTrue(ips)
all_ips.update(ips) all_ips.update(ips)
ips = self.get_test_ips_for_host('invalid_host_name') ips = self.get_test_ips_for_host('invalid_host_name')
self.assertFalse(ips) self.assertFalse(ips)
for ip in all_ips: for ip in all_ips:
host = self.get_test_host_for_ip(ip) host = self.get_test_host_for_ip(ip)
#print ip, host if ip.startswith('127.30.') or ip.startswith('127.40.'):
if ip.startswith('127.30.'):
continue continue
self.assertTrue(host) self.assertTrue(host)
ips = self.get_test_ips_for_host(host) ips = self.get_test_ips_for_host(host)
@@ -1063,7 +1065,6 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
self.assertFalse(host) self.assertFalse(host)
def gethostbyaddr(self, ip): def gethostbyaddr(self, ip):
#print 'gethostbyaddr', ip
if not ip.startswith('127.'): if not ip.startswith('127.'):
return self._original_gethostbyaddr(ip) return self._original_gethostbyaddr(ip)
host = self.get_test_host_for_ip(ip) host = self.get_test_host_for_ip(ip)
@@ -1073,7 +1074,6 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
return (host, [raddr], [ip]) return (host, [raddr], [ip])
def getaddrinfo(self, host, port, family=0, socktype=0, proto=0, flags=0): def getaddrinfo(self, host, port, family=0, socktype=0, proto=0, flags=0):
#print 'getaddrinfo', host, port, family, socktype, proto, flags
if family or socktype or proto or flags: if family or socktype or proto or flags:
return self._original_getaddrinfo(host, port, family, socktype, return self._original_getaddrinfo(host, port, family, socktype,
proto, flags) proto, flags)
@@ -1083,6 +1083,7 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
addrs = [host] addrs = [host]
except socket.error: except socket.error:
addrs = self.get_test_ips_for_host(host) addrs = self.get_test_ips_for_host(host)
addrs = [x for x in addrs if not x.startswith('127.40.')]
if not addrs: if not addrs:
raise socket.gaierror('test host not found') raise socket.gaierror('test host not found')
results = [] results = []
@@ -1094,6 +1095,17 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
return results return results
def test_job_template_callback(self): def test_job_template_callback(self):
# Set ansible_ssh_host for certain hosts, update name to be an alias.
for host in Host.objects.all():
ips = self.get_test_ips_for_host(host.name)
for ip in ips:
if ip.startswith('127.40.'):
host.name = '%s-alias' % host.name
host_vars = host.variables_dict
host_vars['ansible_ssh_host'] = ip
host.variables = json.dumps(host_vars)
host.save()
# Find a valid job template to use to test the callback. # Find a valid job template to use to test the callback.
job_template = None job_template = None
qs = JobTemplate.objects.filter(job_type='run', qs = JobTemplate.objects.filter(job_type='run',
@@ -1106,18 +1118,197 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
break break
self.assertTrue(job_template) self.assertTrue(job_template)
url = reverse('main:job_template_callback', args=(job_template.pk,)) url = reverse('main:job_template_callback', args=(job_template.pk,))
data = dict(host_config_key=job_template.host_config_key)
# Test a POST to start a new job. # Test a POST to start a new job.
with self.current_user(None): host_qs = job_template.inventory.hosts.order_by('pk')
data = dict(host_config_key=job_template.host_config_key) host_qs = host_qs.exclude(variables__icontains='ansible_ssh_host')
host = job_template.inventory.hosts.order_by('-pk')[0] host = host_qs[0]
ip = self.get_test_ips_for_host(host.name)[0] host_ip = self.get_test_ips_for_host(host.name)[0]
jobs_qs = job_template.jobs.filter(launch_type='callback') jobs_qs = job_template.jobs.filter(launch_type='callback').order_by('-pk')
self.assertEqual(jobs_qs.count(), 0) self.assertEqual(jobs_qs.count(), 0)
self.post(url, data, expect=202, remote_addr=ip) self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 1) self.assertEqual(jobs_qs.count(), 1)
job = jobs_qs[0] job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback') self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name) self.assertEqual(job.limit, host.name)
self.assertEqual(job.hosts.count(), 1) self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host) self.assertEqual(job.hosts.all()[0], host)
# GET as unauthenticated user will prompt for authentication.
self.get(url, expect=401, remote_addr=host_ip)
# Test GET (as super user) to validate host.
with self.current_user(self.user_sue):
response = self.get(url, expect=200, remote_addr=host_ip)
self.assertEqual(response['host_config_key'],
job_template.host_config_key)
self.assertEqual(response['matching_hosts'], [host.name])
# POST but leave out the host_config_key.
self.post(url, {}, expect=403, remote_addr=host_ip)
# Try with REMOTE_ADDR empty.
self.post(url, data, expect=400, remote_addr='')
# Try with REMOTE_ADDR set to an unknown address.
self.post(url, data, expect=400, remote_addr='127.127.0.1')
# Try using an alternate IP for the host (but one that also resolves
# via reverse lookup).
host = None
host_ip = None
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.exclude(variables__icontains='ansible_ssh_host')
for h in host_qs:
ips = self.get_test_ips_for_host(h.name)
for ip in ips:
if ip.startswith('127.20.'):
host = h
host_ip = ip
break
if host_ip:
break
self.assertTrue(host)
self.assertEqual(jobs_qs.count(), 1)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 2)
job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name)
self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host)
# Try using an IP for the host that doesn't resolve via reverse lookup,
# but can be found by doing a forward lookup on the host name.
host = None
host_ip = None
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.exclude(variables__icontains='ansible_ssh_host')
for h in host_qs:
ips = self.get_test_ips_for_host(h.name)
for ip in ips:
if ip.startswith('127.30.'):
host = h
host_ip = ip
break
if host_ip:
break
self.assertTrue(host)
self.assertEqual(jobs_qs.count(), 2)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 3)
job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name)
self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host)
# Try using address only specified via ansible_ssh_host.
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.filter(variables__icontains='ansible_ssh_host')
host = host_qs[0]
host_ip = host.variables_dict['ansible_ssh_host']
self.assertEqual(jobs_qs.count(), 3)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 4)
job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name)
self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host)
# Try when hostname is also an IP address, even if a different one is
# specified via ansible_ssh_host.
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.exclude(variables__icontains='ansible_ssh_host')
host = None
host_ip = None
for h in host_qs:
ips = self.get_test_ips_for_host(h.name)
if len(ips) > 1:
host = h
host.name = list(ips)[0]
host_vars = host.variables_dict
host_vars['ansible_ssh_host'] = list(ips)[1]
host.variables = json.dumps(host_vars)
host.save()
host_ip = list(ips)[0]
break
self.assertTrue(host)
self.assertEqual(jobs_qs.count(), 4)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 5)
job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name)
self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host)
# Find a new job template to use.
job_template = None
qs = JobTemplate.objects.filter(job_type='check',
credential__isnull=False)
qs = qs.exclude(host_config_key='')
for jt in qs:
if not jt.can_start_without_user_input():
continue
job_template = jt
break
self.assertTrue(job_template)
url = reverse('main:job_template_callback', args=(job_template.pk,))
data = dict(host_config_key=job_template.host_config_key)
# Should get an error when multiple hosts match to the same IP.
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.exclude(name__endswith='-alias')
for host in host_qs:
host_vars = host.variables_dict
host_vars['ansible_ssh_host'] = '127.50.0.1'
host.variables = json.dumps(host_vars)
host.save()
host = host_qs[0]
host_ip = host.variables_dict['ansible_ssh_host']
self.post(url, data, expect=400, remote_addr=host_ip)
# Find a job template to run that doesn't have a credential.
job_template = None
qs = JobTemplate.objects.filter(job_type='run',
credential__isnull=True)
qs = qs.exclude(host_config_key='')
for jt in qs:
job_template = jt
break
self.assertTrue(job_template)
url = reverse('main:job_template_callback', args=(job_template.pk,))
data = dict(host_config_key=job_template.host_config_key)
# Test POST to start a new job when the template has no credential.
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.exclude(variables__icontains='ansible_ssh_host')
host = host_qs[0]
host_ip = self.get_test_ips_for_host(host.name)[0]
self.post(url, data, expect=400, remote_addr=host_ip)
# Find a job template to run that has a credential but would require
# user input.
job_template = None
qs = JobTemplate.objects.filter(job_type='run',
credential__isnull=False)
qs = qs.exclude(host_config_key='')
for jt in qs:
if jt.can_start_without_user_input():
continue
job_template = jt
break
self.assertTrue(job_template)
url = reverse('main:job_template_callback', args=(job_template.pk,))
data = dict(host_config_key=job_template.host_config_key)
# Test POST to start a new job when the credential would require user
# input.
host_qs = job_template.inventory.hosts.order_by('pk')
host_qs = host_qs.exclude(variables__icontains='ansible_ssh_host')
host = host_qs[0]
host_ip = self.get_test_ips_for_host(host.name)[0]
self.post(url, data, expect=400, remote_addr=host_ip)

View File

@@ -1069,15 +1069,48 @@ class JobTemplateDetail(BaseDetail):
class JobTemplateCallback(generics.RetrieveAPIView): class JobTemplateCallback(generics.RetrieveAPIView):
''' '''
Configure a host to POST to this resource using the `host_config_key`. The job template callback allows for empheral hosts to launch a new job.
Configure a host to POST to this resource, passing the `host_config_key`
parameter, to start a new job limited to only the requesting host. In the
examples below, replace the `N` parameter with the `id` of the job template
and the `HOST_CONFIG_KEY` with the `host_config_key` associated with the
job template.
For example, using curl:
curl --data-urlencode host_config_key=HOST_CONFIG_KEY http://server/api/v1/job_templates/N/callback/
Or using wget:
wget -O /dev/null --post-data="host_config_key=HOST_CONFIG_KEY" http://server/api/v1/job_templates/N/callback/
The response will return status 202 if the request is valid, 403 for an
invalid host config key, or 400 if the host cannot be determined from the
address making the request.
A GET request may be used to verify that the correct host will be selected.
This request must authenticate as a valid user with permission to edit the
job template. For example:
curl http://user:password@server/api/v1/job_templates/N/callback/
The response will include the host config key as well as the host name(s)
that would match the request:
{
"host_config_key": "HOST_CONFIG_KEY",
"matching_hosts": ["hostname"]
}
''' '''
model = JobTemplate model = JobTemplate
permission_classes = (JobTemplateCallbackPermission,) permission_classes = (JobTemplateCallbackPermission,)
def find_host(self): def find_matching_hosts(self):
''' '''
Find the host in the job template's inventory that matches the remote Find the host(s) in the job template's inventory that match the remote
host for the current request. host for the current request.
''' '''
# Find the list of remote host names/IPs to check. # Find the list of remote host names/IPs to check.
@@ -1099,30 +1132,27 @@ class JobTemplateCallback(generics.RetrieveAPIView):
if rh.endswith('.arpa'): if rh.endswith('.arpa'):
remote_hosts.remove(rh) remote_hosts.remove(rh)
if not remote_hosts: if not remote_hosts:
return return set()
# Find the host objects to search for a match. # Find the host objects to search for a match.
obj = self.get_object() obj = self.get_object()
qs = obj.inventory.hosts.filter(active=True) qs = obj.inventory.hosts.filter(active=True)
# First try for an exact match on the name. # First try for an exact match on the name.
try: try:
return qs.get(name__in=remote_hosts) return set([qs.get(name__in=remote_hosts)])
except (Host.DoesNotExist, Host.MultipleObjectsReturned): except (Host.DoesNotExist, Host.MultipleObjectsReturned):
pass pass
# Next, try matching based on name or ansible_ssh_host variable. # Next, try matching based on name or ansible_ssh_host variable.
matches = dict() matches = set()
for host in qs: for host in qs:
ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '') ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '')
if ansible_ssh_host in remote_hosts: if ansible_ssh_host in remote_hosts:
if host not in matches: matches.add(host)
matches[host] = 0 # FIXME: Not entirely sure if this statement will ever be needed?
matches[host] += 2
if host.name != ansible_ssh_host and host.name in remote_hosts: if host.name != ansible_ssh_host and host.name in remote_hosts:
if host not in matches: matches.add(host)
matches[host] = 0
matches[host] += 1
if len(matches) == 1: if len(matches) == 1:
return matches.keys()[0] return matches
# Try to resolve forward addresses for each host to find a match. # Try to resolve forward addresses for each host to find matches.
for host in qs: for host in qs:
hostnames = set([host.name]) hostnames = set([host.name])
ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '') ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '')
@@ -1134,22 +1164,18 @@ class JobTemplateCallback(generics.RetrieveAPIView):
possible_ips = set(x[4][0] for x in result) possible_ips = set(x[4][0] for x in result)
possible_ips.discard(hostname) possible_ips.discard(hostname)
if possible_ips and possible_ips & remote_hosts: if possible_ips and possible_ips & remote_hosts:
if host in matches: matches.add(host)
matches[host] += 1
else:
matches[host] = 1
except socket.gaierror: except socket.gaierror:
pass pass
# Return the host with the highest match weight (in case of multiple # Return all matches found.
# matches). return matches
if matches:
return sorted(matches.items(), key=lambda x: x[1])[-1][0]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
job_template = self.get_object() job_template = self.get_object()
matching_hosts = self.find_matching_hosts()
data = dict( data = dict(
host_config_key=job_template.host_config_key, host_config_key=job_template.host_config_key,
matched_host=getattr(self.find_host(), 'name', None), matching_hosts=[x.name for x in matching_hosts],
) )
if settings.DEBUG: if settings.DEBUG:
d = dict([(k,v) for k,v in request.META.items() d = dict([(k,v) for k,v in request.META.items()
@@ -1160,12 +1186,20 @@ class JobTemplateCallback(generics.RetrieveAPIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
job_template = self.get_object() job_template = self.get_object()
# Permission class should have already validated host_config_key. # Permission class should have already validated host_config_key.
host = self.find_host() matching_hosts = self.find_matching_hosts()
if not host: if not matching_hosts:
data = dict(msg='No matching host could be found!') data = dict(msg='No matching host could be found!')
# FIXME: Log!
return Response(data, status=400) return Response(data, status=400)
elif len(matching_hosts) > 1:
data = dict(msg='Multiple hosts matched the request!')
# FIXME: Log!
return Response(data, status=400)
else:
host = list(matching_hosts)[0]
if not job_template.can_start_without_user_input(): if not job_template.can_start_without_user_input():
data = dict(msg='Cannot start automatically, user input required!') data = dict(msg='Cannot start automatically, user input required!')
# FIXME: Log!
return Response(data, status=400) return Response(data, status=400)
limit = ':'.join(filter(None, [job_template.limit, host.name])) limit = ':'.join(filter(None, [job_template.limit, host.name]))
job = job_template.create_job(limit=limit, launch_type='callback') job = job_template.create_job(limit=limit, launch_type='callback')