diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 73de37ee2b..002c6929fb 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -160,7 +160,7 @@ SUMMARIZABLE_FK_FIELDS = { 'default_environment': DEFAULT_SUMMARY_FIELDS + ('image',), 'execution_environment': DEFAULT_SUMMARY_FIELDS + ('image',), 'project': DEFAULT_SUMMARY_FIELDS + ('status', 'scm_type', 'allow_override'), - 'source_project': DEFAULT_SUMMARY_FIELDS + ('status', 'scm_type'), + 'source_project': DEFAULT_SUMMARY_FIELDS + ('status', 'scm_type', 'allow_override'), 'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed'), 'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'kubernetes', 'credential_type_id'), 'signature_validation_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'credential_type_id'), @@ -2128,6 +2128,7 @@ class InventorySourceOptionsSerializer(BaseSerializer): 'source', 'source_path', 'source_vars', + 'scm_branch', 'credential', 'enabled_var', 'enabled_value', @@ -2292,10 +2293,14 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None: raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")}) else: - redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path'])) + redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path', 'scm_branch'])) if redundant_scm_fields: raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))}) + project = get_field_from_model_or_attrs('source_project') + if get_field_from_model_or_attrs('scm_branch') and not project.allow_override: + raise serializers.ValidationError({'scm_branch': _('Project does not allow overriding branch.')}) + attrs = super(InventorySourceSerializer, self).validate(attrs) # Check type consistency of source and cloud credential, if provided diff --git a/awx/main/migrations/0175_inventorysource_scm_branch.py b/awx/main/migrations/0175_inventorysource_scm_branch.py new file mode 100644 index 0000000000..56eed2da7f --- /dev/null +++ b/awx/main/migrations/0175_inventorysource_scm_branch.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.16 on 2023-03-03 20:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0174_ensure_org_ee_admin_roles'), + ] + + operations = [ + migrations.AddField( + model_name='inventorysource', + name='scm_branch', + field=models.CharField( + blank=True, + default='', + help_text='Inventory source SCM branch. Project default used if blank. Only allowed if project allow_override field is set to true.', + max_length=1024, + ), + ), + migrations.AddField( + model_name='inventoryupdate', + name='scm_branch', + field=models.CharField( + blank=True, + default='', + help_text='Inventory source SCM branch. Project default used if blank. Only allowed if project allow_override field is set to true.', + max_length=1024, + ), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index a0df4dfde4..7b55c51851 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -872,6 +872,12 @@ class InventorySourceOptions(BaseModel): default='', help_text=_('Inventory source variables in YAML or JSON format.'), ) + scm_branch = models.CharField( + max_length=1024, + default='', + blank=True, + help_text=_('Inventory source SCM branch. Project default used if blank. Only allowed if project allow_override field is set to true.'), + ) enabled_var = models.TextField( blank=True, default='', diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 369e045e6f..9da4bc074b 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -759,7 +759,7 @@ class SourceControlMixin(BaseTask): def sync_and_copy(self, project, private_data_dir, scm_branch=None): self.acquire_lock(project, self.instance.id) - + is_commit = False try: original_branch = None failed_reason = project.get_reason_if_failed() @@ -771,6 +771,7 @@ class SourceControlMixin(BaseTask): if os.path.exists(project_path): git_repo = git.Repo(project_path) if git_repo.head.is_detached: + is_commit = True original_branch = git_repo.head.commit else: original_branch = git_repo.active_branch @@ -782,7 +783,11 @@ class SourceControlMixin(BaseTask): # for git project syncs, non-default branches can be problems # restore to branch the repo was on before this run try: - original_branch.checkout() + if is_commit: + git_repo.head.set_commit(original_branch) + git_repo.head.reset(index=True, working_tree=True) + else: + original_branch.checkout() except Exception: # this could have failed due to dirty tree, but difficult to predict all cases logger.exception(f'Failed to restore project repo to prior state after {self.instance.id}') @@ -1581,7 +1586,7 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): if inventory_update.source == 'scm': if not source_project: raise RuntimeError('Could not find project to run SCM inventory update from.') - self.sync_and_copy(source_project, private_data_dir) + self.sync_and_copy(source_project, private_data_dir, scm_branch=inventory_update.inventory_source.scm_branch) else: # If source is not SCM make an empty project directory, content is built inside inventory folder super(RunInventoryUpdate, self).build_project_dir(inventory_update, private_data_dir) diff --git a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js index 87e9ece5cd..cf5d1354af 100644 --- a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js +++ b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js @@ -48,6 +48,7 @@ function InventorySourceDetail({ inventorySource }) { source, source_path, source_vars, + scm_branch, update_cache_timeout, update_on_launch, verbosity, @@ -233,6 +234,11 @@ function InventorySourceDetail({ inventorySource }) { helpText={helpText.subFormVerbosityFields} value={VERBOSITY()[verbosity]} /> + ({ }, enabledVariableField: t`Retrieve the enabled state from the given dict of host variables. The enabled variable may be specified using dot notation, e.g: 'foo.bar'`, + sourceControlBranch: t`Branch to use on inventory sync. Project default used if blank. Only allowed if project allow_override field is set to true.`, enabledValue: t`This field is ignored unless an Enabled Variable is set. If the enabled variable matches this value, the host will be enabled on import.`, hostFilter: t`Regular expression where only matching host names will be imported. The filter is applied as a post-processing step after any inventory plugin filters are applied.`, sourceVars: (docsBaseUrl, source) => { diff --git a/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js b/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js index 7131ed16bf..88baa8d03d 100644 --- a/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js +++ b/awx/ui/src/screens/Inventory/shared/InventorySourceForm.js @@ -71,6 +71,7 @@ const InventorySourceFormFields = ({ source_project: null, source_script: null, source_vars: '---\n', + scm_branch: null, update_cache_timeout: 0, update_on_launch: false, verbosity: 1, @@ -248,6 +249,7 @@ const InventorySourceForm = ({ source_project: source?.summary_fields?.source_project || null, source_script: source?.summary_fields?.source_script || null, source_vars: source?.source_vars || '---\n', + scm_branch: source?.scm_branch || '', update_cache_timeout: source?.update_cache_timeout || 0, update_on_launch: source?.update_on_launch || false, verbosity: source?.verbosity || 1, diff --git a/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.js b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.js index 1b6d3e95ba..043ddcebe7 100644 --- a/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.js +++ b/awx/ui/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.js @@ -13,6 +13,7 @@ import { required } from 'util/validators'; import CredentialLookup from 'components/Lookup/CredentialLookup'; import ProjectLookup from 'components/Lookup/ProjectLookup'; import Popover from 'components/Popover'; +import FormField from 'components/FormField'; import { OptionsField, SourceVarsField, @@ -36,7 +37,6 @@ const SCMSubForm = ({ autoPopulateProject }) => { name: 'source_path', validate: required(t`Select a value for this field`), }); - const { error: sourcePathError, request: fetchSourcePath } = useRequest( useCallback(async (projectId) => { const { data } = await ProjectsAPI.readInventories(projectId); @@ -44,7 +44,6 @@ const SCMSubForm = ({ autoPopulateProject }) => { }, []), [] ); - useEffect(() => { if (projectMeta.initialValue) { fetchSourcePath(projectMeta.initialValue.id); @@ -58,6 +57,7 @@ const SCMSubForm = ({ autoPopulateProject }) => { (value) => { setFieldValue('source_project', value); setFieldTouched('source_project', true, false); + setFieldValue('scm_branch', '', false); if (sourcePathField.value) { setFieldValue('source_path', ''); setFieldTouched('source_path', false); @@ -68,7 +68,6 @@ const SCMSubForm = ({ autoPopulateProject }) => { }, [fetchSourcePath, setFieldValue, setFieldTouched, sourcePathField.value] ); - const handleCredentialUpdate = useCallback( (value) => { setFieldValue('credential', value); @@ -76,9 +75,17 @@ const SCMSubForm = ({ autoPopulateProject }) => { }, [setFieldValue, setFieldTouched] ); - return ( <> + {projectField.value?.allow_override && ( + + )}