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';