diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index c441e6ce1a..1b47de4f45 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -885,7 +885,9 @@ class SourceControlMixin(BaseTask): # Determine whether or not this project sync needs to populate the cache for Ansible content, roles and collections has_cache = os.path.exists(os.path.join(project.get_cache_path(), project.cache_id)) # Galaxy requirements are not supported for manual projects - if project.scm_type and ((not has_cache) or branch_override): + # If a source update is scheduled, always include roles/collections because + # the new revision may have different requirements. + if project.scm_type and ((not has_cache) or branch_override or source_update_tag in sync_needs): sync_needs.extend(['install_roles', 'install_collections']) return sync_needs diff --git a/awx/main/tests/unit/tasks/test_jobs.py b/awx/main/tests/unit/tasks/test_jobs.py index 0fc0fa98b6..ffe99cb2f6 100644 --- a/awx/main/tests/unit/tasks/test_jobs.py +++ b/awx/main/tests/unit/tasks/test_jobs.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os import pytest from unittest import mock @@ -661,3 +662,82 @@ class TestRunInventoryUpdatePopulateWorkloadIdentityTokens: # The instance's get_cloud_credential should now return the same object with context assert task.instance.get_cloud_credential() is cloud_cred + + +class TestGetSyncNeeds: + """Tests for SourceControlMixin.get_sync_needs cache behavior.""" + + def _make_task_and_project(self, tmp_path, scm_revision='abc123', local_head='abc123', cache_exists=True): + """Create a SourceControlMixin instance and a mock project for testing get_sync_needs.""" + project_path = str(tmp_path / 'project') + os.makedirs(project_path, exist_ok=True) + + cache_base = str(tmp_path / 'cache') + os.makedirs(cache_base, exist_ok=True) + if cache_exists and scm_revision: + os.makedirs(os.path.join(cache_base, scm_revision), exist_ok=True) + + project = mock.MagicMock() + project.scm_type = 'git' + project.scm_revision = scm_revision + project.scm_branch = 'main' + project.get_project_path.return_value = project_path + project.get_cache_path.return_value = cache_base + project.cache_id = scm_revision if scm_revision else 'fallback' + + git_repo = mock.MagicMock() + git_repo.head.commit.hexsha = local_head + + task = jobs.SourceControlMixin() + task.instance = mock.MagicMock(spec=Job) + + return task, project, git_repo + + def test_source_update_forces_role_install(self, tmp_path): + """When a source update is needed (local HEAD differs from project revision), + install_roles and install_collections must be included even if cache exists. + A new revision may have changed requirements.yml.""" + task, project, git_repo = self._make_task_and_project( + tmp_path, + scm_revision='abc123', + local_head='def456', + cache_exists=True, + ) + + with mock.patch('awx.main.tasks.jobs.git.Repo', return_value=git_repo): + sync_needs = task.get_sync_needs(project) + + assert 'update_git' in sync_needs + assert 'install_roles' in sync_needs + assert 'install_collections' in sync_needs + + def test_no_sync_when_revision_matches_and_cache_exists(self, tmp_path): + """When local HEAD matches project revision and cache exists, no sync needed.""" + task, project, git_repo = self._make_task_and_project( + tmp_path, + scm_revision='abc123', + local_head='abc123', + cache_exists=True, + ) + + with mock.patch('awx.main.tasks.jobs.git.Repo', return_value=git_repo): + sync_needs = task.get_sync_needs(project) + + assert sync_needs == [] + + def test_cache_miss_triggers_role_install(self, tmp_path): + """When cache doesn't exist, install_roles and install_collections are needed + even if the git revision matches.""" + task, project, git_repo = self._make_task_and_project( + tmp_path, + scm_revision='abc123', + local_head='abc123', + cache_exists=False, + ) + + with mock.patch('awx.main.tasks.jobs.git.Repo', return_value=git_repo): + sync_needs = task.get_sync_needs(project) + + assert 'update_git' not in sync_needs + assert 'install_roles' in sync_needs + assert 'install_collections' in sync_needs