mirror of
https://github.com/ansible/awx.git
synced 2026-05-05 16:37:37 -02:30
Add terraform state inventory source (#14840)
* Add terraform state inventory source * Update inventory source plugin test Signed-off-by: Helen Bailey <hebailey@redhat.com>
This commit is contained in:
@@ -14,7 +14,7 @@ __all__ = [
|
|||||||
'STANDARD_INVENTORY_UPDATE_ENV',
|
'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 = [
|
PRIVILEGE_ESCALATION_METHODS = [
|
||||||
('sudo', _('Sudo')),
|
('sudo', _('Sudo')),
|
||||||
('su', _('Su')),
|
('su', _('Su')),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -925,6 +925,7 @@ class InventorySourceOptions(BaseModel):
|
|||||||
('rhv', _('Red Hat Virtualization')),
|
('rhv', _('Red Hat Virtualization')),
|
||||||
('controller', _('Red Hat Ansible Automation Platform')),
|
('controller', _('Red Hat Ansible Automation Platform')),
|
||||||
('insights', _('Red Hat Insights')),
|
('insights', _('Red Hat Insights')),
|
||||||
|
('terraform', _('Terraform State')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# From the options of the Django management base command
|
# From the options of the Django management base command
|
||||||
@@ -1630,6 +1631,20 @@ class satellite6(PluginFileInjector):
|
|||||||
return ret
|
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):
|
class controller(PluginFileInjector):
|
||||||
plugin_name = 'tower' # TODO: relying on routing for now, update after EEs pick up revised collection
|
plugin_name = 'tower' # TODO: relying on routing for now, update after EEs pick up revised collection
|
||||||
base_injector = 'template'
|
base_injector = 'template'
|
||||||
|
|||||||
3
awx/main/tests/data/inventory/plugins/terraform/env.json
Normal file
3
awx/main/tests/data/inventory/plugins/terraform/env.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"TF_BACKEND_CONFIG_FILE": "{{ file_reference }}"
|
||||||
|
}
|
||||||
@@ -193,6 +193,7 @@ class TestInventorySourceInjectors:
|
|||||||
('satellite6', 'theforeman.foreman.foreman'),
|
('satellite6', 'theforeman.foreman.foreman'),
|
||||||
('insights', 'redhatinsights.insights.insights'),
|
('insights', 'redhatinsights.insights.insights'),
|
||||||
('controller', 'awx.awx.tower'),
|
('controller', 'awx.awx.tower'),
|
||||||
|
('terraform', 'cloud.terraform.terraform_state'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_plugin_proper_names(self, source, proper_name):
|
def test_plugin_proper_names(self, source, proper_name):
|
||||||
|
|||||||
@@ -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)):
|
for filename in os.listdir(os.path.join(private_data_dir, subdir)):
|
||||||
filename_list.append(os.path.join(subdir, filename))
|
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])
|
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:
|
for filename in filename_list:
|
||||||
if filename in ('args', 'project'):
|
if filename in ('args', 'project'):
|
||||||
continue # Ansible runner
|
continue # Ansible runner
|
||||||
@@ -130,6 +131,7 @@ def read_content(private_data_dir, raw_env, inventory_update):
|
|||||||
dir_contents[abs_file_path] = f.read()
|
dir_contents[abs_file_path] = f.read()
|
||||||
# Declare a reference to inventory plugin file if it exists
|
# Declare a reference to inventory plugin file if it exists
|
||||||
if abs_file_path.endswith('.yml') and 'plugin: ' in dir_contents[abs_file_path]:
|
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
|
referenced_paths.add(abs_file_path) # used as inventory file
|
||||||
elif cache_file_regex.match(abs_file_path):
|
elif cache_file_regex.match(abs_file_path):
|
||||||
file_aliases[abs_file_path] = 'cache_file'
|
file_aliases[abs_file_path] = 'cache_file'
|
||||||
@@ -157,7 +159,11 @@ def read_content(private_data_dir, raw_env, inventory_update):
|
|||||||
content = {}
|
content = {}
|
||||||
for abs_file_path, file_content in dir_contents.items():
|
for abs_file_path, file_content in dir_contents.items():
|
||||||
# assert that all files laid down are used
|
# 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(
|
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))
|
"File {} is not referenced. References and files:\n{}\n{}".format(abs_file_path, json.dumps(env, indent=4), json.dumps(dir_contents, indent=4))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -759,6 +759,14 @@ SATELLITE6_INSTANCE_ID_VAR = 'foreman_id,foreman.id'
|
|||||||
INSIGHTS_INSTANCE_ID_VAR = 'insights_id'
|
INSIGHTS_INSTANCE_ID_VAR = 'insights_id'
|
||||||
INSIGHTS_EXCLUDE_EMPTY_GROUPS = False
|
INSIGHTS_EXCLUDE_EMPTY_GROUPS = False
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# -- Terraform State --
|
||||||
|
# ----------------
|
||||||
|
# TERRAFORM_ENABLED_VAR =
|
||||||
|
# TERRAFORM_ENABLED_VALUE =
|
||||||
|
TERRAFORM_INSTANCE_ID_VAR = 'id'
|
||||||
|
TERRAFORM_EXCLUDE_EMPTY_GROUPS = True
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# ----- Custom -----
|
# ----- Custom -----
|
||||||
# ---------------------
|
# ---------------------
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const ansibleDocUrls = {
|
|||||||
'https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html',
|
'https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html',
|
||||||
constructed:
|
constructed:
|
||||||
'https://docs.ansible.com/ansible/latest/collections/ansible/builtin/constructed_inventory.html',
|
'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 = () => ({
|
const getInventoryHelpTextStrings = () => ({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SCMSubForm,
|
SCMSubForm,
|
||||||
SatelliteSubForm,
|
SatelliteSubForm,
|
||||||
ControllerSubForm,
|
ControllerSubForm,
|
||||||
|
TerraformSubForm,
|
||||||
VMwareSubForm,
|
VMwareSubForm,
|
||||||
VirtualizationSubForm,
|
VirtualizationSubForm,
|
||||||
} from './InventorySourceSubForms';
|
} from './InventorySourceSubForms';
|
||||||
@@ -214,6 +215,14 @@ const InventorySourceFormFields = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
terraform: (
|
||||||
|
<TerraformSubForm
|
||||||
|
autoPopulateCredential={
|
||||||
|
!source?.id || source?.source !== 'terraform'
|
||||||
|
}
|
||||||
|
sourceOptions={sourceOptions}
|
||||||
|
/>
|
||||||
|
),
|
||||||
vmware: (
|
vmware: (
|
||||||
<VMwareSubForm
|
<VMwareSubForm
|
||||||
autoPopulateCredential={
|
autoPopulateCredential={
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('<InventorySourceForm />', () => {
|
|||||||
['openstack', 'OpenStack'],
|
['openstack', 'OpenStack'],
|
||||||
['rhv', 'Red Hat Virtualization'],
|
['rhv', 'Red Hat Virtualization'],
|
||||||
['controller', 'Red Hat Ansible Automation Platform'],
|
['controller', 'Red Hat Ansible Automation Platform'],
|
||||||
|
['terraform', 'Terraform State'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<CredentialLookup
|
||||||
|
credentialTypeNamespace="terraform"
|
||||||
|
label={t`Credential`}
|
||||||
|
helperTextInvalid={credentialMeta.error}
|
||||||
|
isValid={!credentialMeta.touched || !credentialMeta.error}
|
||||||
|
onBlur={() => credentialHelpers.setTouched()}
|
||||||
|
onChange={handleCredentialUpdate}
|
||||||
|
value={credentialField.value}
|
||||||
|
required
|
||||||
|
autoPopulate={autoPopulateCredential}
|
||||||
|
validate={required(t`Select a value for this field`)}
|
||||||
|
/>
|
||||||
|
<VerbosityField />
|
||||||
|
<HostFilterField />
|
||||||
|
<EnabledVarField />
|
||||||
|
<EnabledValueField />
|
||||||
|
<OptionsField />
|
||||||
|
<SourceVarsField
|
||||||
|
popoverContent={helpText.sourceVars(docsBaseUrl, 'terraform')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TerraformSubForm;
|
||||||
@@ -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('<TerraformSubForm />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
CredentialsAPI.read.mockResolvedValue({
|
||||||
|
data: { count: 0, results: [] },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Formik initialValues={initialValues}>
|
||||||
|
<TerraformSubForm sourceOptions={mockSourceOptions} />
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,5 +6,6 @@ export { default as OpenStackSubForm } from './OpenStackSubForm';
|
|||||||
export { default as SCMSubForm } from './SCMSubForm';
|
export { default as SCMSubForm } from './SCMSubForm';
|
||||||
export { default as SatelliteSubForm } from './SatelliteSubForm';
|
export { default as SatelliteSubForm } from './SatelliteSubForm';
|
||||||
export { default as ControllerSubForm } from './ControllerSubForm';
|
export { default as ControllerSubForm } from './ControllerSubForm';
|
||||||
|
export { default as TerraformSubForm } from './TerraformSubForm';
|
||||||
export { default as VMwareSubForm } from './VMwareSubForm';
|
export { default as VMwareSubForm } from './VMwareSubForm';
|
||||||
export { default as VirtualizationSubForm } from './VirtualizationSubForm';
|
export { default as VirtualizationSubForm } from './VirtualizationSubForm';
|
||||||
|
|||||||
Reference in New Issue
Block a user