1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-26 07:55:24 +03:00

Make cloud providers dynamic (#15537)

* Add dynamic pull for cloud inventory plugins and update corresponding tests

Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>

* Create third dictionary to preserve current functionality and add 'file' there

* Migrations for corresponding change

---------

Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>
This commit is contained in:
Lila Yasin 2024-10-23 11:30:00 -04:00 committed by GitHub
parent c85fa70745
commit e21dd0a093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 108 additions and 64 deletions

View File

@ -102,7 +102,6 @@ from awx.main.models import (
WorkflowJobTemplate, WorkflowJobTemplate,
WorkflowJobTemplateNode, WorkflowJobTemplateNode,
StdoutMaxBytesExceeded, StdoutMaxBytesExceeded,
CLOUD_INVENTORY_SOURCES,
) )
from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES
from awx.main.models.rbac import role_summary_fields_generator, give_creator_permissions, get_role_codenames, to_permissions, get_role_from_object_role from awx.main.models.rbac import role_summary_fields_generator, give_creator_permissions, get_role_codenames, to_permissions, get_role_from_object_role
@ -119,7 +118,9 @@ from awx.main.utils import (
truncate_stdout, truncate_stdout,
get_licenser, get_licenser,
) )
from awx.main.utils.filters import SmartFilter from awx.main.utils.filters import SmartFilter
from awx.main.utils.plugins import load_combined_inventory_source_options
from awx.main.utils.named_url_graph import reset_counters from awx.main.utils.named_url_graph import reset_counters
from awx.main.scheduler.task_manager_models import TaskManagerModels from awx.main.scheduler.task_manager_models import TaskManagerModels
from awx.main.redact import UriCleaner, REPLACE_STR from awx.main.redact import UriCleaner, REPLACE_STR
@ -2300,6 +2301,7 @@ class GroupVariableDataSerializer(BaseVariableDataSerializer):
class InventorySourceOptionsSerializer(BaseSerializer): class InventorySourceOptionsSerializer(BaseSerializer):
credential = DeprecatedCredentialField(help_text=_('Cloud credential to use for inventory updates.')) credential = DeprecatedCredentialField(help_text=_('Cloud credential to use for inventory updates.'))
source = serializers.ChoiceField(choices=[])
class Meta: class Meta:
fields = ( fields = (
@ -2321,6 +2323,11 @@ class InventorySourceOptionsSerializer(BaseSerializer):
) )
read_only_fields = ('*', 'custom_virtualenv') read_only_fields = ('*', 'custom_virtualenv')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'source' in self.fields:
self.fields['source'].choices = load_combined_inventory_source_options()
def get_related(self, obj): def get_related(self, obj):
res = super(InventorySourceOptionsSerializer, self).get_related(obj) res = super(InventorySourceOptionsSerializer, self).get_related(obj)
if obj.credential: # TODO: remove when 'credential' field is removed if obj.credential: # TODO: remove when 'credential' field is removed
@ -5500,7 +5507,7 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
return summary_fields return summary_fields
def validate_unified_job_template(self, value): def validate_unified_job_template(self, value):
if type(value) == InventorySource and value.source not in CLOUD_INVENTORY_SOURCES: if type(value) == InventorySource and value.source not in load_combined_inventory_source_options():
raise serializers.ValidationError(_('Inventory Source must be a cloud resource.')) raise serializers.ValidationError(_('Inventory Source must be a cloud resource.'))
elif type(value) == Project and value.scm_type == '': elif type(value) == Project and value.scm_type == '':
raise serializers.ValidationError(_('Manual Project cannot have a schedule set.')) raise serializers.ValidationError(_('Manual Project cannot have a schedule set.'))

View File

@ -100,6 +100,7 @@ from awx.main.utils import (
) )
from awx.main.utils.encryption import encrypt_value from awx.main.utils.encryption import encrypt_value
from awx.main.utils.filters import SmartFilter from awx.main.utils.filters import SmartFilter
from awx.main.utils.plugins import compute_cloud_inventory_sources
from awx.main.redact import UriCleaner from awx.main.redact import UriCleaner
from awx.api.permissions import ( from awx.api.permissions import (
JobTemplateCallbackPermission, JobTemplateCallbackPermission,
@ -2196,9 +2197,9 @@ class InventorySourceNotificationTemplatesAnyList(SubListCreateAttachDetachAPIVi
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
parent = self.get_parent_object() parent = self.get_parent_object()
if parent.source not in models.CLOUD_INVENTORY_SOURCES: if parent.source not in compute_cloud_inventory_sources():
return Response( return Response(
dict(msg=_("Notification Templates can only be assigned when source is one of {}.").format(models.CLOUD_INVENTORY_SOURCES, parent.source)), dict(msg=_("Notification Templates can only be assigned when source is one of {}.").format(compute_cloud_inventory_sources(), parent.source)),
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
return super(InventorySourceNotificationTemplatesAnyList, self).post(request, *args, **kwargs) return super(InventorySourceNotificationTemplatesAnyList, self).post(request, *args, **kwargs)

View File

@ -6,7 +6,6 @@ import re
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
__all__ = [ __all__ = [
'CLOUD_PROVIDERS',
'PRIVILEGE_ESCALATION_METHODS', 'PRIVILEGE_ESCALATION_METHODS',
'ANSI_SGR_PATTERN', 'ANSI_SGR_PATTERN',
'CAN_CANCEL', 'CAN_CANCEL',
@ -14,25 +13,6 @@ __all__ = [
'STANDARD_INVENTORY_UPDATE_ENV', 'STANDARD_INVENTORY_UPDATE_ENV',
] ]
CLOUD_PROVIDERS = (
'azure_rm',
'ec2',
'gce',
'vmware',
'openstack',
'rhv',
'satellite6',
'controller',
'insights',
'terraform',
'openshift_virtualization',
'controller_supported',
'rhv_supported',
'openshift_virtualization_supported',
'insights_supported',
'satellite6_supported',
)
PRIVILEGE_ESCALATION_METHODS = [ PRIVILEGE_ESCALATION_METHODS = [
('sudo', _('Sudo')), ('sudo', _('Sudo')),
('su', _('Su')), ('su', _('Su')),

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-10-22 15:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0197_remove_sso_app_content'),
]
operations = [
migrations.AlterField(
model_name='inventorysource',
name='source',
field=models.CharField(default=None, max_length=32),
),
migrations.AlterField(
model_name='inventoryupdate',
name='source',
field=models.CharField(default=None, max_length=32),
),
]

View File

@ -16,7 +16,7 @@ from ansible_base.lib.utils.models import prevent_search
from ansible_base.lib.utils.models import user_summary_fields from ansible_base.lib.utils.models import user_summary_fields
# AWX # AWX
from awx.main.models.base import BaseModel, PrimordialModel, accepts_json, CLOUD_INVENTORY_SOURCES, VERBOSITY_CHOICES # noqa from awx.main.models.base import BaseModel, PrimordialModel, accepts_json, VERBOSITY_CHOICES # noqa
from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate, StdoutMaxBytesExceeded # noqa from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate, StdoutMaxBytesExceeded # noqa
from awx.main.models.organization import Organization, Team, UserSessionMembership # noqa from awx.main.models.organization import Organization, Team, UserSessionMembership # noqa
from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa

View File

@ -15,7 +15,6 @@ from crum import get_current_user
# AWX # AWX
from awx.main.utils import encrypt_field, parse_yaml_or_json from awx.main.utils import encrypt_field, parse_yaml_or_json
from awx.main.constants import CLOUD_PROVIDERS
__all__ = [ __all__ = [
'VarsDictProperty', 'VarsDictProperty',
@ -32,7 +31,6 @@ __all__ = [
'JOB_TYPE_CHOICES', 'JOB_TYPE_CHOICES',
'AD_HOC_JOB_TYPE_CHOICES', 'AD_HOC_JOB_TYPE_CHOICES',
'PROJECT_UPDATE_JOB_TYPE_CHOICES', 'PROJECT_UPDATE_JOB_TYPE_CHOICES',
'CLOUD_INVENTORY_SOURCES',
'VERBOSITY_CHOICES', 'VERBOSITY_CHOICES',
] ]
@ -61,7 +59,6 @@ PROJECT_UPDATE_JOB_TYPE_CHOICES = [
(PERM_INVENTORY_CHECK, _('Check')), (PERM_INVENTORY_CHECK, _('Check')),
] ]
CLOUD_INVENTORY_SOURCES = list(CLOUD_PROVIDERS) + ['scm']
VERBOSITY_CHOICES = [ VERBOSITY_CHOICES = [
(0, '0 (Normal)'), (0, '0 (Normal)'),

View File

@ -28,7 +28,7 @@ from awx_plugins.inventory.plugins import PluginFileInjector
# AWX # AWX
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.constants import CLOUD_PROVIDERS from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names, compute_cloud_inventory_sources
from awx.main.consumers import emit_channel_notification from awx.main.consumers import emit_channel_notification
from awx.main.fields import ( from awx.main.fields import (
ImplicitRoleField, ImplicitRoleField,
@ -36,7 +36,7 @@ from awx.main.fields import (
OrderedManyToManyField, OrderedManyToManyField,
) )
from awx.main.managers import HostManager, HostMetricActiveManager from awx.main.managers import HostManager, HostMetricActiveManager
from awx.main.models.base import BaseModel, CommonModelNameNotUnique, VarsDictProperty, CLOUD_INVENTORY_SOURCES, accepts_json from awx.main.models.base import BaseModel, CommonModelNameNotUnique, VarsDictProperty, accepts_json
from awx.main.models.events import InventoryUpdateEvent, UnpartitionedInventoryUpdateEvent from awx.main.models.events import InventoryUpdateEvent, UnpartitionedInventoryUpdateEvent
from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate
from awx.main.models.mixins import ( from awx.main.models.mixins import (
@ -394,7 +394,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
if self.kind == 'smart': if self.kind == 'smart':
active_inventory_sources = self.inventory_sources.none() active_inventory_sources = self.inventory_sources.none()
else: else:
active_inventory_sources = self.inventory_sources.filter(source__in=CLOUD_INVENTORY_SOURCES) active_inventory_sources = self.inventory_sources.filter(source__in=compute_cloud_inventory_sources())
failed_inventory_sources = active_inventory_sources.filter(last_job_failed=True) failed_inventory_sources = active_inventory_sources.filter(last_job_failed=True)
total_hosts = active_hosts.count() total_hosts = active_hosts.count()
# if total_hosts has changed, set update_task_impact to True # if total_hosts has changed, set update_task_impact to True
@ -914,23 +914,6 @@ class InventorySourceOptions(BaseModel):
injectors = dict() injectors = dict()
SOURCE_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')),
('openshift_virtualization', _('OpenShift Virtualization')),
]
# From the options of the Django management base command # From the options of the Django management base command
INVENTORY_UPDATE_VERBOSITY_CHOICES = [ INVENTORY_UPDATE_VERBOSITY_CHOICES = [
(0, '0 (WARNING)'), (0, '0 (WARNING)'),
@ -943,7 +926,6 @@ class InventorySourceOptions(BaseModel):
source = models.CharField( source = models.CharField(
max_length=32, max_length=32,
choices=SOURCE_CHOICES,
blank=False, blank=False,
default=None, default=None,
) )
@ -1047,7 +1029,7 @@ class InventorySourceOptions(BaseModel):
# Allow an EC2 source to omit the credential. If Tower is running on # Allow an EC2 source to omit the credential. If Tower is running on
# an EC2 instance with an IAM Role assigned, boto will use credentials # an EC2 instance with an IAM Role assigned, boto will use credentials
# from the instance metadata instead of those explicitly provided. # from the instance metadata instead of those explicitly provided.
elif source in CLOUD_PROVIDERS and source not in ['ec2', 'openshift_virtualization']: elif source in discover_available_cloud_provider_plugin_names() and source not in ['ec2', 'openshift_virtualization']:
return _('Credential is required for a cloud source.') return _('Credential is required for a cloud source.')
elif source == 'custom' and cred and cred.credential_type.kind in ('scm', 'ssh', 'insights', 'vault'): elif source == 'custom' and cred and cred.credential_type.kind in ('scm', 'ssh', 'insights', 'vault'):
return _('Credentials of type machine, source control, insights and vault are disallowed for custom inventory sources.') return _('Credentials of type machine, source control, insights and vault are disallowed for custom inventory sources.')
@ -1061,11 +1043,8 @@ class InventorySourceOptions(BaseModel):
"""Return the credential which is directly tied to the inventory source type.""" """Return the credential which is directly tied to the inventory source type."""
credential = None credential = None
for cred in self.credentials.all(): for cred in self.credentials.all():
if self.source in CLOUD_PROVIDERS: if self.source in discover_available_cloud_provider_plugin_names():
source = self.source.replace('ec2', 'aws') if cred.kind == self.source.replace('ec2', 'aws'):
if source.endswith('_supported'):
source = source[:-10]
if cred.kind == source:
credential = cred credential = cred
break break
else: else:
@ -1080,7 +1059,7 @@ class InventorySourceOptions(BaseModel):
These are all credentials that should run their own inject_credential logic. These are all credentials that should run their own inject_credential logic.
""" """
special_cred = None special_cred = None
if self.source in CLOUD_PROVIDERS: if self.source in discover_available_cloud_provider_plugin_names():
# these have special injection logic associated with them # these have special injection logic associated with them
special_cred = self.get_cloud_credential() special_cred = self.get_cloud_credential()
extra_creds = [] extra_creds = []

View File

@ -5,8 +5,8 @@ from unittest import mock
# AWX # AWX
from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job
from awx.main.constants import CLOUD_PROVIDERS
from awx.main.utils.filters import SmartFilter from awx.main.utils.filters import SmartFilter
from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names
@pytest.mark.django_db @pytest.mark.django_db
@ -166,11 +166,11 @@ class TestInventorySourceInjectors:
def test_all_cloud_sources_covered(self): def test_all_cloud_sources_covered(self):
"""Code in several places relies on the fact that the older """Code in several places relies on the fact that the older
CLOUD_PROVIDERS constant contains the same names as what are discover_cloud_provider_plugin_names returns the same names as what are
defined within the injectors defined within the injectors
""" """
# slight exception case for constructed, because it has a FQCN but is not a cloud source # slight exception case for constructed, because it has a FQCN but is not a cloud source
assert set(CLOUD_PROVIDERS) | set(['constructed']) == set(InventorySource.injectors.keys()) assert set(discover_available_cloud_provider_plugin_names()) | set(['constructed']) == set(InventorySource.injectors.keys())
@pytest.mark.parametrize('source,filename', [('ec2', 'aws_ec2.yml'), ('openstack', 'openstack.yml'), ('gce', 'gcp_compute.yml')]) @pytest.mark.parametrize('source,filename', [('ec2', 'aws_ec2.yml'), ('openstack', 'openstack.yml'), ('gce', 'gcp_compute.yml')])
def test_plugin_filenames(self, source, filename): def test_plugin_filenames(self, source, filename):

View File

@ -9,9 +9,9 @@ from awx_plugins.interfaces._temporary_private_container_api import get_incontai
from awx.main.tasks.jobs import RunInventoryUpdate from awx.main.tasks.jobs import RunInventoryUpdate
from awx.main.models import InventorySource, Credential, CredentialType, UnifiedJob, ExecutionEnvironment from awx.main.models import InventorySource, Credential, CredentialType, UnifiedJob, ExecutionEnvironment
from awx.main.constants import CLOUD_PROVIDERS, STANDARD_INVENTORY_UPDATE_ENV from awx.main.constants import STANDARD_INVENTORY_UPDATE_ENV
from awx.main.tests import data from awx.main.tests import data
from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names
from django.conf import settings from django.conf import settings
DATA = os.path.join(os.path.dirname(data.__file__), 'inventory') DATA = os.path.join(os.path.dirname(data.__file__), 'inventory')
@ -193,7 +193,7 @@ def create_reference_data(source_dir, env, content):
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize('this_kind', CLOUD_PROVIDERS) @pytest.mark.parametrize('this_kind', discover_available_cloud_provider_plugin_names())
def test_inventory_update_injected_content(this_kind, inventory, fake_credential_factory, mock_me): def test_inventory_update_injected_content(this_kind, inventory, fake_credential_factory, mock_me):
if this_kind.endswith('_supported'): if this_kind.endswith('_supported'):
this_kind = this_kind[:-10] this_kind = this_kind[:-10]
@ -202,8 +202,6 @@ def test_inventory_update_injected_content(this_kind, inventory, fake_credential
ExecutionEnvironment.objects.create(name='Default Job EE', managed=False) ExecutionEnvironment.objects.create(name='Default Job EE', managed=False)
injector = InventorySource.injectors[this_kind] injector = InventorySource.injectors[this_kind]
if injector.plugin_name is None:
pytest.skip('Use of inventory plugin is not enabled for this source')
src_vars = dict(base_source_var='value_of_var') src_vars = dict(base_source_var='value_of_var')
src_vars['plugin'] = injector.get_proper_name() src_vars['plugin'] = injector.get_proper_name()

59
awx/main/utils/plugins.py Normal file
View File

@ -0,0 +1,59 @@
# Copyright (c) 2024 Ansible, Inc.
# All Rights Reserved.
"""
This module contains the code responsible for extracting the lists of dynamically discovered plugins.
"""
from functools import cache
@cache
def discover_available_cloud_provider_plugin_names() -> list[str]:
"""
Return a list of cloud plugin names available in runtime.
The discovery result is cached since it does not change throughout
the life cycle of the server run.
:returns: List of plugin cloud names.
:rtype: list[str]
"""
from awx.main.models.inventory import InventorySourceOptions
plugin_names = list(InventorySourceOptions.injectors.keys())
plugin_names.remove('constructed')
return plugin_names
@cache
def compute_cloud_inventory_sources() -> dict[str, str]:
"""
Return a dictionary of cloud provider plugin names
available plus source control management and constructed.
:returns: Dictionary of plugin cloud names plus source control.
:rtype: dict[str, str]
"""
plugins = discover_available_cloud_provider_plugin_names()
return dict(zip(plugins, plugins), scm='scm', constructed='constructed')
@cache
def load_combined_inventory_source_options() -> dict[str, str]:
"""
Return a dictionary of cloud provider plugin names and 'file'.
The 'file' entry is included separately since it needs to be consumed directly by the serializer.
:returns: A dictionary of cloud provider plugin names (as both keys and values) plus the 'file' entry.
:rtype: dict[str, str]
"""
plugins = compute_cloud_inventory_sources()
return dict(zip(plugins, plugins), file='file')