diff --git a/awx/main/rbac.py b/awx/main/rbac.py index 979b592904..3db7c6aeaf 100644 --- a/awx/main/rbac.py +++ b/awx/main/rbac.py @@ -123,7 +123,11 @@ class CustomRbac(permissions.BasePermission): return self.has_permission(request, view, obj) class JobTemplateCallbackPermission(CustomRbac): - + ''' + Permission check used by job template callback view for requests from + empheral hosts. + ''' + def has_permission(self, request, view, obj=None): # If another authentication method was used and it's not a POST, return # True to fall through to the next permission class. @@ -135,17 +139,20 @@ class JobTemplateCallbackPermission(CustomRbac): # active in order to proceed. host_config_key = request.DATA.get('host_config_key', '') if request.method.lower() != 'post': - return False + raise PermissionDenied() elif not host_config_key: - return False + raise PermissionDenied() elif obj and not obj.active: - return False + raise PermissionDenied() elif obj and obj.host_config_key != host_config_key: - return False + raise PermissionDenied() else: return True class JobTaskPermission(CustomRbac): + ''' + Permission checks used for API callbacks from running a task. + ''' def has_permission(self, request, view, obj=None): # If another authentication method was used other than the one for job diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index 143985fa0e..c4b648d3e5 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -153,7 +153,7 @@ class BaseTestMixin(object): client_kwargs = {} if accept: client_kwargs['HTTP_ACCEPT'] = accept - if remote_addr: + if remote_addr is not None: client_kwargs['REMOTE_ADDR'] = remote_addr client = Client(**client_kwargs) auth = auth or self._current_auth diff --git a/awx/main/tests/jobs.py b/awx/main/tests/jobs.py index 8c7c954842..5d9b92f476 100644 --- a/awx/main/tests/jobs.py +++ b/awx/main/tests/jobs.py @@ -360,11 +360,11 @@ class BaseJobTestMixin(BaseTestMixin): project=self.proj_test, playbook=self.proj_test.playbooks[0], host_config_key=uuid.uuid4().hex, + credential=self.cred_eve, created_by=self.user_sue, ) self.job_sup_run = self.jt_sup_run.create_job( created_by=self.user_sue, - credential=self.cred_eve, ) # 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.''' ips = [] 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). val = self.atoh('127.10.0.0') + h.pk ips.append(self.htoa(val)) @@ -1028,6 +1028,10 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): if h.pk % 3 == 0: val = self.atoh('127.30.0.0') + h.pk 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: pass return ips @@ -1046,15 +1050,13 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): all_ips = set() for host in Host.objects.all(): ips = self.get_test_ips_for_host(host.name) - #print host, ips self.assertTrue(ips) all_ips.update(ips) ips = self.get_test_ips_for_host('invalid_host_name') self.assertFalse(ips) for ip in all_ips: host = self.get_test_host_for_ip(ip) - #print ip, host - if ip.startswith('127.30.'): + if ip.startswith('127.30.') or ip.startswith('127.40.'): continue self.assertTrue(host) ips = self.get_test_ips_for_host(host) @@ -1063,7 +1065,6 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): self.assertFalse(host) def gethostbyaddr(self, ip): - #print 'gethostbyaddr', ip if not ip.startswith('127.'): return self._original_gethostbyaddr(ip) host = self.get_test_host_for_ip(ip) @@ -1073,7 +1074,6 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): return (host, [raddr], [ip]) 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: return self._original_getaddrinfo(host, port, family, socktype, proto, flags) @@ -1083,6 +1083,7 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): addrs = [host] except socket.error: addrs = self.get_test_ips_for_host(host) + addrs = [x for x in addrs if not x.startswith('127.40.')] if not addrs: raise socket.gaierror('test host not found') results = [] @@ -1094,6 +1095,17 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): return results 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. job_template = None qs = JobTemplate.objects.filter(job_type='run', @@ -1106,18 +1118,197 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): 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 a POST to start a new job. - with self.current_user(None): - data = dict(host_config_key=job_template.host_config_key) - host = job_template.inventory.hosts.order_by('-pk')[0] - ip = self.get_test_ips_for_host(host.name)[0] - jobs_qs = job_template.jobs.filter(launch_type='callback') - self.assertEqual(jobs_qs.count(), 0) - self.post(url, data, expect=202, remote_addr=ip) - self.assertEqual(jobs_qs.count(), 1) - 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) + 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] + jobs_qs = job_template.jobs.filter(launch_type='callback').order_by('-pk') + self.assertEqual(jobs_qs.count(), 0) + self.post(url, data, expect=202, remote_addr=host_ip) + self.assertEqual(jobs_qs.count(), 1) + 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) + + # 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) diff --git a/awx/main/views.py b/awx/main/views.py index d40e3d76bb..c2404044f2 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -1069,15 +1069,48 @@ class JobTemplateDetail(BaseDetail): 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 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. ''' # Find the list of remote host names/IPs to check. @@ -1099,30 +1132,27 @@ class JobTemplateCallback(generics.RetrieveAPIView): if rh.endswith('.arpa'): remote_hosts.remove(rh) if not remote_hosts: - return + return set() # Find the host objects to search for a match. obj = self.get_object() qs = obj.inventory.hosts.filter(active=True) # First try for an exact match on the name. try: - return qs.get(name__in=remote_hosts) + return set([qs.get(name__in=remote_hosts)]) except (Host.DoesNotExist, Host.MultipleObjectsReturned): pass # Next, try matching based on name or ansible_ssh_host variable. - matches = dict() + matches = set() for host in qs: ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '') if ansible_ssh_host in remote_hosts: - if host not in matches: - matches[host] = 0 - matches[host] += 2 + matches.add(host) + # FIXME: Not entirely sure if this statement will ever be needed? if host.name != ansible_ssh_host and host.name in remote_hosts: - if host not in matches: - matches[host] = 0 - matches[host] += 1 + matches.add(host) if len(matches) == 1: - return matches.keys()[0] - # Try to resolve forward addresses for each host to find a match. + return matches + # Try to resolve forward addresses for each host to find matches. for host in qs: hostnames = set([host.name]) 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.discard(hostname) if possible_ips and possible_ips & remote_hosts: - if host in matches: - matches[host] += 1 - else: - matches[host] = 1 + matches.add(host) except socket.gaierror: pass - # Return the host with the highest match weight (in case of multiple - # matches). - if matches: - return sorted(matches.items(), key=lambda x: x[1])[-1][0] + # Return all matches found. + return matches def get(self, request, *args, **kwargs): job_template = self.get_object() + matching_hosts = self.find_matching_hosts() data = dict( 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: 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): job_template = self.get_object() # Permission class should have already validated host_config_key. - host = self.find_host() - if not host: + matching_hosts = self.find_matching_hosts() + if not matching_hosts: data = dict(msg='No matching host could be found!') + # FIXME: Log! 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(): data = dict(msg='Cannot start automatically, user input required!') + # FIXME: Log! return Response(data, status=400) limit = ':'.join(filter(None, [job_template.limit, host.name])) job = job_template.create_job(limit=limit, launch_type='callback')