From 7f2a1b6b030b02886ca7e8851f032d464eb52302 Mon Sep 17 00:00:00 2001 From: Helen Bailey Date: Wed, 6 Mar 2024 15:27:52 -0500 Subject: [PATCH] Add terraform state inventory source (#14840) * Add terraform state inventory source * Update inventory source plugin test Signed-off-by: Helen Bailey --- awx/main/constants.py | 2 +- ...0_alter_inventorysource_source_and_more.py | 59 ++++++++++++++++ awx/main/models/inventory.py | 15 ++++ .../data/inventory/plugins/terraform/env.json | 3 + .../tests/functional/models/test_inventory.py | 1 + .../test_inventory_source_injectors.py | 8 ++- awx/settings/defaults.py | 8 +++ .../Inventory/shared/Inventory.helptext.js | 2 + .../Inventory/shared/InventorySourceForm.js | 9 +++ .../shared/InventorySourceForm.test.js | 1 + .../TerraformSubForm.js | 59 ++++++++++++++++ .../TerraformSubForm.test.js | 70 +++++++++++++++++++ .../shared/InventorySourceSubForms/index.js | 1 + 13 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 awx/main/migrations/0190_alter_inventorysource_source_and_more.py create mode 100644 awx/main/tests/data/inventory/plugins/terraform/env.json create mode 100644 awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.js create mode 100644 awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.test.js diff --git a/awx/main/constants.py b/awx/main/constants.py index 66666f875f..8800edc334 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -14,7 +14,7 @@ __all__ = [ 'STANDARD_INVENTORY_UPDATE_ENV', ] -CLOUD_PROVIDERS = ('azure_rm', 'ec2', 'gce', 'vmware', 'openstack', 'rhv', 'satellite6', 'controller', 'insights') +CLOUD_PROVIDERS = ('azure_rm', 'ec2', 'gce', 'vmware', 'openstack', 'rhv', 'satellite6', 'controller', 'insights', 'terraform') PRIVILEGE_ESCALATION_METHODS = [ ('sudo', _('Sudo')), ('su', _('Su')), diff --git a/awx/main/migrations/0190_alter_inventorysource_source_and_more.py b/awx/main/migrations/0190_alter_inventorysource_source_and_more.py new file mode 100644 index 0000000000..0c1eb703ed --- /dev/null +++ b/awx/main/migrations/0190_alter_inventorysource_source_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.6 on 2024-02-15 20:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0189_inbound_hop_nodes'), + ] + + operations = [ + migrations.AlterField( + model_name='inventorysource', + name='source', + field=models.CharField( + choices=[ + ('file', 'File, Directory or Script'), + ('constructed', 'Template additional groups and hostvars at runtime'), + ('scm', 'Sourced from a Project'), + ('ec2', 'Amazon EC2'), + ('gce', 'Google Compute Engine'), + ('azure_rm', 'Microsoft Azure Resource Manager'), + ('vmware', 'VMware vCenter'), + ('satellite6', 'Red Hat Satellite 6'), + ('openstack', 'OpenStack'), + ('rhv', 'Red Hat Virtualization'), + ('controller', 'Red Hat Ansible Automation Platform'), + ('insights', 'Red Hat Insights'), + ('terraform', 'Terraform State'), + ], + default=None, + max_length=32, + ), + ), + migrations.AlterField( + model_name='inventoryupdate', + name='source', + field=models.CharField( + choices=[ + ('file', 'File, Directory or Script'), + ('constructed', 'Template additional groups and hostvars at runtime'), + ('scm', 'Sourced from a Project'), + ('ec2', 'Amazon EC2'), + ('gce', 'Google Compute Engine'), + ('azure_rm', 'Microsoft Azure Resource Manager'), + ('vmware', 'VMware vCenter'), + ('satellite6', 'Red Hat Satellite 6'), + ('openstack', 'OpenStack'), + ('rhv', 'Red Hat Virtualization'), + ('controller', 'Red Hat Ansible Automation Platform'), + ('insights', 'Red Hat Insights'), + ('terraform', 'Terraform State'), + ], + default=None, + max_length=32, + ), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 71c6eb0a92..8554f2f245 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -925,6 +925,7 @@ class InventorySourceOptions(BaseModel): ('rhv', _('Red Hat Virtualization')), ('controller', _('Red Hat Ansible Automation Platform')), ('insights', _('Red Hat Insights')), + ('terraform', _('Terraform State')), ] # From the options of the Django management base command @@ -1630,6 +1631,20 @@ class satellite6(PluginFileInjector): return ret +class terraform(PluginFileInjector): + plugin_name = 'terraform_state' + base_injector = 'managed' + namespace = 'cloud' + collection = 'terraform' + use_fqcn = True + + def inventory_as_dict(self, inventory_update, private_data_dir): + env = super(terraform, self).get_plugin_env(inventory_update, private_data_dir, None) + ret = super().inventory_as_dict(inventory_update, private_data_dir) + ret['backend_config_files'] = env["TF_BACKEND_CONFIG_FILE"] + return ret + + class controller(PluginFileInjector): plugin_name = 'tower' # TODO: relying on routing for now, update after EEs pick up revised collection base_injector = 'template' diff --git a/awx/main/tests/data/inventory/plugins/terraform/env.json b/awx/main/tests/data/inventory/plugins/terraform/env.json new file mode 100644 index 0000000000..c68086c8d7 --- /dev/null +++ b/awx/main/tests/data/inventory/plugins/terraform/env.json @@ -0,0 +1,3 @@ +{ + "TF_BACKEND_CONFIG_FILE": "{{ file_reference }}" +} diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index 27d2f46ec8..a07ef1b21c 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -193,6 +193,7 @@ class TestInventorySourceInjectors: ('satellite6', 'theforeman.foreman.foreman'), ('insights', 'redhatinsights.insights.insights'), ('controller', 'awx.awx.tower'), + ('terraform', 'cloud.terraform.terraform_state'), ], ) def test_plugin_proper_names(self, source, proper_name): diff --git a/awx/main/tests/functional/test_inventory_source_injectors.py b/awx/main/tests/functional/test_inventory_source_injectors.py index 903a6c9875..80bc5429c1 100644 --- a/awx/main/tests/functional/test_inventory_source_injectors.py +++ b/awx/main/tests/functional/test_inventory_source_injectors.py @@ -107,6 +107,7 @@ def read_content(private_data_dir, raw_env, inventory_update): for filename in os.listdir(os.path.join(private_data_dir, subdir)): filename_list.append(os.path.join(subdir, filename)) filename_list = sorted(filename_list, key=lambda fn: inverse_env.get(os.path.join(private_data_dir, fn), [fn])[0]) + inventory_content = "" for filename in filename_list: if filename in ('args', 'project'): continue # Ansible runner @@ -130,6 +131,7 @@ def read_content(private_data_dir, raw_env, inventory_update): dir_contents[abs_file_path] = f.read() # Declare a reference to inventory plugin file if it exists if abs_file_path.endswith('.yml') and 'plugin: ' in dir_contents[abs_file_path]: + inventory_content = dir_contents[abs_file_path] referenced_paths.add(abs_file_path) # used as inventory file elif cache_file_regex.match(abs_file_path): file_aliases[abs_file_path] = 'cache_file' @@ -157,7 +159,11 @@ def read_content(private_data_dir, raw_env, inventory_update): content = {} for abs_file_path, file_content in dir_contents.items(): # assert that all files laid down are used - if abs_file_path not in referenced_paths and abs_file_path not in ignore_files: + if ( + abs_file_path not in referenced_paths + and to_container_path(abs_file_path, private_data_dir) not in inventory_content + and abs_file_path not in ignore_files + ): raise AssertionError( "File {} is not referenced. References and files:\n{}\n{}".format(abs_file_path, json.dumps(env, indent=4), json.dumps(dir_contents, indent=4)) ) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 7ae79b043c..1e2fed59b9 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -759,6 +759,14 @@ SATELLITE6_INSTANCE_ID_VAR = 'foreman_id,foreman.id' INSIGHTS_INSTANCE_ID_VAR = 'insights_id' INSIGHTS_EXCLUDE_EMPTY_GROUPS = False +# ---------------- +# -- Terraform State -- +# ---------------- +# TERRAFORM_ENABLED_VAR = +# TERRAFORM_ENABLED_VALUE = +TERRAFORM_INSTANCE_ID_VAR = 'id' +TERRAFORM_EXCLUDE_EMPTY_GROUPS = True + # --------------------- # ----- Custom ----- # --------------------- diff --git a/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js b/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js index 5345c115bb..8f81048639 100644 --- a/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js +++ b/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js @@ -21,6 +21,8 @@ const ansibleDocUrls = { 'https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html', constructed: 'https://docs.ansible.com/ansible/latest/collections/ansible/builtin/constructed_inventory.html', + terraform: + 'https://github.com/ansible-collections/cloud.terraform/blob/stable-statefile-inventory/plugins/inventory/terraform_state.py', }; const getInventoryHelpTextStrings = () => ({ diff --git a/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js b/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js index 88baa8d03d..06172d961a 100644 --- a/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js +++ b/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js @@ -23,6 +23,7 @@ import { SCMSubForm, SatelliteSubForm, ControllerSubForm, + TerraformSubForm, VMwareSubForm, VirtualizationSubForm, } from './InventorySourceSubForms'; @@ -214,6 +215,14 @@ const InventorySourceFormFields = ({ } /> ), + terraform: ( + + ), vmware: ( ', () => { ['openstack', 'OpenStack'], ['rhv', 'Red Hat Virtualization'], ['controller', 'Red Hat Ansible Automation Platform'], + ['terraform', 'Terraform State'], ], }, }, diff --git a/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.js b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.js new file mode 100644 index 0000000000..8a83210d6c --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.js @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react'; +import { useField, useFormikContext } from 'formik'; +import { t } from '@lingui/macro'; +import getDocsBaseUrl from 'util/getDocsBaseUrl'; +import { useConfig } from 'contexts/Config'; +import CredentialLookup from 'components/Lookup/CredentialLookup'; +import { required } from 'util/validators'; +import { + OptionsField, + VerbosityField, + EnabledVarField, + EnabledValueField, + HostFilterField, + SourceVarsField, +} from './SharedFields'; +import getHelpText from '../Inventory.helptext'; + +const TerraformSubForm = ({ autoPopulateCredential }) => { + const helpText = getHelpText(); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = + useField('credential'); + const config = useConfig(); + const handleCredentialUpdate = useCallback( + (value) => { + setFieldValue('credential', value); + setFieldTouched('credential', true, false); + }, + [setFieldValue, setFieldTouched] + ); + const docsBaseUrl = getDocsBaseUrl(config); + + return ( + <> + credentialHelpers.setTouched()} + onChange={handleCredentialUpdate} + value={credentialField.value} + required + autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} + /> + + + + + + + + ); +}; + +export default TerraformSubForm; diff --git a/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.test.js b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.test.js new file mode 100644 index 0000000000..4250ab4e84 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/TerraformSubForm.test.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { CredentialsAPI } from 'api'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import TerraformSubForm from './TerraformSubForm'; + +jest.mock('../../../../api'); + +const initialValues = { + credential: null, + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + verbosity: 1, +}; + +const mockSourceOptions = { + actions: { + POST: {}, + }, +}; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('should render subform fields', () => { + expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'terraform', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/index.js b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/index.js index d9ab42201a..27bcf5a315 100644 --- a/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/index.js +++ b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/index.js @@ -6,5 +6,6 @@ export { default as OpenStackSubForm } from './OpenStackSubForm'; export { default as SCMSubForm } from './SCMSubForm'; export { default as SatelliteSubForm } from './SatelliteSubForm'; export { default as ControllerSubForm } from './ControllerSubForm'; +export { default as TerraformSubForm } from './TerraformSubForm'; export { default as VMwareSubForm } from './VMwareSubForm'; export { default as VirtualizationSubForm } from './VirtualizationSubForm';