From 06c75aeecfffff83a9ee8f3b0aa48c8af4be4a63 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Tue, 30 Sep 2014 11:50:25 -0400 Subject: [PATCH] Implement API side for custom inventory script support with endpoints and unit tests --- awx/api/serializers.py | 7 ++++++- awx/api/urls.py | 6 ++++++ awx/api/views.py | 11 +++++++++++ awx/main/access.py | 27 +++++++++++++++++++++++++++ awx/main/models/base.py | 2 +- awx/main/models/inventory.py | 28 ++++++++++++++++++++++++++-- awx/main/tasks.py | 14 +++++++++++++- awx/main/tests/inventory.py | 36 ++++++++++++++++++++++++++++++++++++ 8 files changed, 126 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c2b97efb4a..9b4b0f3bc5 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -929,11 +929,16 @@ class GroupVariableDataSerializer(BaseVariableDataSerializer): class Meta: model = Group +class CustomInventoryScriptSerializer(BaseSerializer): + + class Meta: + model = CustomInventoryScript + fields = ('*', "script") class InventorySourceOptionsSerializer(BaseSerializer): class Meta: - fields = ('*', 'source', 'source_path', 'source_vars', 'credential', + fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential', 'source_regions', 'overwrite', 'overwrite_vars') def get_related(self, obj): diff --git a/awx/api/urls.py b/awx/api/urls.py index b1517d983a..8999adfda1 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -116,6 +116,11 @@ inventory_update_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/stdout/$', 'inventory_update_stdout'), ) +inventory_script_urls = patterns('awx.api.views', + url(r'^$', 'inventory_script_list'), + url(r'^(?P[0-9]+)/$', 'inventory_script_detail'), +) + credential_urls = patterns('awx.api.views', url(r'^$', 'credential_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'credential_activity_stream_list'), @@ -193,6 +198,7 @@ v1_urls = patterns('awx.api.views', url(r'^groups/', include(group_urls)), url(r'^inventory_sources/', include(inventory_source_urls)), url(r'^inventory_updates/', include(inventory_update_urls)), + url(r'^inventory_scripts/', include(inventory_script_urls)), url(r'^credentials/', include(credential_urls)), url(r'^permissions/', include(permission_urls)), url(r'^job_templates/', include(job_template_urls)), diff --git a/awx/api/views.py b/awx/api/views.py index c5bed9ec1d..df925e3bdf 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -102,6 +102,7 @@ class ApiV1RootView(APIView): data['teams'] = reverse('api:team_list') data['credentials'] = reverse('api:credential_list') data['inventory'] = reverse('api:inventory_list') + data['inventory_scripts'] = reverse('api:inventory_script_list') data['inventory_sources'] = reverse('api:inventory_source_list') data['groups'] = reverse('api:group_list') data['hosts'] = reverse('api:host_list') @@ -806,6 +807,16 @@ class PermissionDetail(RetrieveUpdateDestroyAPIView): model = Permission serializer_class = PermissionSerializer +class InventoryScriptList(ListCreateAPIView): + + model = CustomInventoryScript + serializer_class = CustomInventoryScriptSerializer + +class InventoryScriptDetail(RetrieveUpdateDestroyAPIView): + + model = CustomInventoryScript + serializer_class = CustomInventoryScriptSerializer + class InventoryList(ListCreateAPIView): model = Inventory diff --git a/awx/main/access.py b/awx/main/access.py index b2ba36ae85..f894eba6f6 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1323,6 +1323,32 @@ class ActivityStreamAccess(BaseAccess): def can_delete(self, obj): return False +class CustomInventoryScriptAccess(BaseAccess): + + model = CustomInventoryScript + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + return qs + + def can_read(self, obj): + return True + + def can_add(self, data): + if self.user.is_superuser: + return True + return False + + def can_change(self, obj, data): + if self.user.is_superuser: + return True + return False + + def can_delete(self, obj): + if self.user.is_superuser: + return True + return False + register_access(User, UserAccess) register_access(Organization, OrganizationAccess) register_access(Inventory, InventoryAccess) @@ -1343,3 +1369,4 @@ register_access(Schedule, ScheduleAccess) register_access(UnifiedJobTemplate, UnifiedJobTemplateAccess) register_access(UnifiedJob, UnifiedJobAccess) register_access(ActivityStream, ActivityStreamAccess) +register_access(CustomInventoryScript, CustomInventoryScriptAccess) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 5fbfa4c730..c70804ce60 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -61,7 +61,7 @@ PERMISSION_TYPE_CHOICES = [ (PERM_INVENTORY_CHECK, _('Deploy To Inventory (Dry Run)')), ] -CLOUD_INVENTORY_SOURCES = ['ec2', 'rax', 'vmware', 'gce', 'azure'] +CLOUD_INVENTORY_SOURCES = ['ec2', 'rax', 'vmware', 'gce', 'azure', 'custom'] class VarsDictProperty(object): diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 9852eb9c0c..235fe934ad 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -39,7 +39,7 @@ from awx.main.models.jobs import Job from awx.main.models.unified_jobs import * from awx.main.utils import encrypt_field, ignore_inventory_computed_fields, _inventory_updates -__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate'] +__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'CustomInventoryScript'] logger = logging.getLogger('awx.main.models.inventory') @@ -744,6 +744,7 @@ class InventorySourceOptions(BaseModel): ('gce', _('Google Compute Engine')), ('azure', _('Microsoft Azure')), ('vmware', _('VMware vCenter')), + ('custom', _('Custom Script')), ] class Meta: @@ -761,6 +762,13 @@ class InventorySourceOptions(BaseModel): default='', editable=False, ) + source_script = models.ForeignKey( + 'CustomInventoryScript', + null=True, + default=None, + blank=True, + on_delete=models.SET_NULL, + ) source_vars = models.TextField( blank=True, default='', @@ -930,7 +938,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions): @classmethod def _get_unified_job_field_names(cls): - return ['name', 'description', 'source', 'source_path', 'source_vars', + return ['name', 'description', 'source', 'source_path', 'source_script', 'source_vars', 'credential', 'source_regions', 'overwrite', 'overwrite_vars'] def save(self, *args, **kwargs): @@ -1067,3 +1075,19 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions): @property def task_impact(self): return 50 + +class CustomInventoryScript(CommonModel): + + class Meta: + app_label = 'main' + ordering = ('name',) + + script = models.TextField( + blank=True, + default='', + help_text=_('Inventory script contents'), + ) + + def get_absolute_url(self): + return reverse('api:inventory_script_detail', args=(self.pk,)) + diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 16c5207586..9bb5ff4afe 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1110,7 +1110,19 @@ class RunInventoryUpdate(BaseTask): elif inventory_update.source == 'file': args.append(inventory_update.source_path) - + elif inventory_update.source == 'custom': + runpath = tempfile.mkdtemp(prefix='ansible_tower_launch_') + handle, path = tempfile.mkstemp(dir=runpath) + f = os.fdopen(handle, 'w') + # Check that source_script is not none and exists and that .script is not an empty string + f.write(inventory_update.source_script.script) + f.close() + os.chmod(path, stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR) + args.append(runpath) + try: + shutil.rmtree(runpath, True) + except OSError: + pass verbosity = getattr(settings, 'INVENTORY_UPDATE_VERBOSITY', 1) args.append('-v%d' % verbosity) if settings.DEBUG: diff --git a/awx/main/tests/inventory.py b/awx/main/tests/inventory.py index ac26d197b9..642c3ae966 100644 --- a/awx/main/tests/inventory.py +++ b/awx/main/tests/inventory.py @@ -21,6 +21,8 @@ from awx.main.tests.base import BaseTest, BaseTransactionTest __all__ = ['InventoryTest', 'InventoryUpdatesTest'] +TEST_SIMPLE_INVENTORY_SCRIPT = "#!/usr/bin/env python\nimport json\nprint json.dumps({'hosts': ['ahost-01', 'ahost-02', 'ahost-03', 'ahost-04']})" + class InventoryTest(BaseTest): def setUp(self): @@ -267,6 +269,17 @@ class InventoryTest(BaseTest): self.delete(permission_detail, expect=204, auth=self.get_super_credentials()) self.get(inventory_detail, expect=403, auth=self.get_other_credentials()) + def test_create_inventory_script(self): + inventory_scripts = reverse('api:inventory_script_list') + new_script = dict(name="Test", description="Test Script", script=TEST_SIMPLE_INVENTORY_SCRIPT) + script_data = self.post(inventory_scripts, data=new_script, expect=201, auth=self.get_super_credentials()) + + got = self.get(inventory_scripts, expect=200, auth=self.get_super_credentials()) + self.assertEquals(got['count'], 1) + + new_failed_script = dict(name="Shouldfail", description="This test should fail", script=TEST_SIMPLE_INVENTORY_SCRIPT) + self.post(inventory_scripts, data=new_failed_script, expect=403, auth=self.get_normal_credentials()) + def test_main_line(self): # some basic URLs... @@ -1546,3 +1559,26 @@ class InventoryUpdatesTest(BaseTransactionTest): # its own child). self.assertTrue(self.group in self.inventory.root_groups) + def test_update_from_custom_script(self): + # Create the inventory script + inventory_scripts = reverse('api:inventory_script_list') + new_script = dict(name="Test", description="Test Script", script=TEST_SIMPLE_INVENTORY_SCRIPT) + script_data = self.post(inventory_scripts, data=new_script, expect=201, auth=self.get_super_credentials()) + + custom_inv = self.organization.inventories.create(name='Custom Script Inventory') + custom_group = custom_inv.groups.create(name="Custom Script Group") + custom_inv_src = reverse('api:inventory_source_detail', + args=(custom_group.inventory_source.pk,)) + custom_inv_update = reverse('api:inventory_source_update_view', + args=(custom_group.inventory_source.pk,)) + inv_src_opts = {'source': 'custom', + 'source_script': script_data["id"]} + with self.current_user(self.super_django_user): + response = self.put(custom_inv_src, inv_src_opts, expect=200) + + with self.current_user(self.super_django_user): + response = self.get(custom_inv_update, expect=200) + self.assertTrue(response['can_update']) + with self.current_user(self.super_django_user): + response = self.post(custom_inv_update, {}, expect=202) + self.assertTrue(custom_group.hosts.all().count() == 4)