Integrate content caching with existing task logic

Revert the --force flags

use the update id as metric for role caching

Shift the movement of cache to job folder from rsync task to python

Only install roles and collections if needed

Deal with roles and collections for jobs without sync
Skip local copy if roles or collections turned off

update docs for content caching

Design pivot - use empty cache dir to indicate lack of content

Do not cache content if we did not install content

Test changes to allay concerns about reliability of local_path

Do not blow away cache for SCM inventory updates
This commit is contained in:
AlanCoding
2020-06-15 10:33:27 -04:00
parent 72848fa097
commit fefab89815
11 changed files with 196 additions and 110 deletions

View File

@@ -423,6 +423,10 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
return True return True
return False return False
@property
def cache_id(self):
return str(self.last_job_id)
@property @property
def notification_templates(self): def notification_templates(self):
base_notification_templates = NotificationTemplate.objects base_notification_templates = NotificationTemplate.objects
@@ -560,6 +564,19 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
def result_stdout_raw(self): def result_stdout_raw(self):
return self._result_stdout_raw(redact_sensitive=True) return self._result_stdout_raw(redact_sensitive=True)
@property
def branch_override(self):
"""Whether a branch other than the project default is used."""
if not self.project:
return True
return bool(self.scm_branch and self.scm_branch != self.project.scm_branch)
@property
def cache_id(self):
if self.branch_override or self.job_type == 'check' or (not self.project):
return str(self.id)
return self.project.cache_id
def result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True): def result_stdout_raw_limited(self, start_line=0, end_line=None, redact_sensitive=True):
return self._result_stdout_raw_limited(start_line, end_line, redact_sensitive=redact_sensitive) return self._result_stdout_raw_limited(start_line, end_line, redact_sensitive=redact_sensitive)
@@ -603,9 +620,7 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
added_update_fields = [] added_update_fields = []
if not self.job_tags: if not self.job_tags:
job_tags = ['update_{}'.format(self.scm_type)] job_tags = ['update_{}'.format(self.scm_type), 'install_roles', 'install_collections']
job_tags.append('install_roles')
job_tags.append('install_collections')
self.job_tags = ','.join(job_tags) self.job_tags = ','.join(job_tags)
added_update_fields.append('job_tags') added_update_fields.append('job_tags')
if self.scm_delete_on_update and 'delete' not in self.job_tags and self.job_type == 'check': if self.scm_delete_on_update and 'delete' not in self.job_tags and self.job_type == 'check':

View File

@@ -1865,44 +1865,32 @@ class RunJob(BaseTask):
project_path = job.project.get_project_path(check_if_exists=False) project_path = job.project.get_project_path(check_if_exists=False)
job_revision = job.project.scm_revision job_revision = job.project.scm_revision
sync_needs = [] sync_needs = []
all_sync_needs = ['update_{}'.format(job.project.scm_type), 'install_roles', 'install_collections'] source_update_tag = 'update_{}'.format(job.project.scm_type)
branch_override = bool(job.scm_branch and job.scm_branch != job.project.scm_branch)
if not job.project.scm_type: if not job.project.scm_type:
pass # manual projects are not synced, user has responsibility for that pass # manual projects are not synced, user has responsibility for that
elif not os.path.exists(project_path): elif not os.path.exists(project_path):
logger.debug('Performing fresh clone of {} on this instance.'.format(job.project)) logger.debug('Performing fresh clone of {} on this instance.'.format(job.project))
sync_needs = all_sync_needs sync_needs.append(source_update_tag)
elif not job.project.scm_revision: elif job.project.scm_type == 'git' and job.project.scm_revision and (not branch_override):
logger.debug('Revision not known for {}, will sync with remote'.format(job.project))
sync_needs = all_sync_needs
elif job.project.scm_type == 'git':
git_repo = git.Repo(project_path) git_repo = git.Repo(project_path)
try: try:
desired_revision = job.project.scm_revision if job_revision == git_repo.head.commit.hexsha:
if job.scm_branch and job.scm_branch != job.project.scm_branch:
desired_revision = job.scm_branch # could be commit or not, but will try as commit
current_revision = git_repo.head.commit.hexsha
if desired_revision == current_revision:
job_revision = desired_revision
logger.debug('Skipping project sync for {} because commit is locally available'.format(job.log_format)) logger.debug('Skipping project sync for {} because commit is locally available'.format(job.log_format))
else: else:
sync_needs = all_sync_needs sync_needs.append(source_update_tag)
except (ValueError, BadGitName): except (ValueError, BadGitName):
logger.debug('Needed commit for {} not in local source tree, will sync with remote'.format(job.log_format)) logger.debug('Needed commit for {} not in local source tree, will sync with remote'.format(job.log_format))
sync_needs = all_sync_needs sync_needs.append(source_update_tag)
else: else:
sync_needs = all_sync_needs logger.debug('Project not available locally {}, will sync with remote'.format(job.project))
# Galaxy requirements are not supported for manual projects sync_needs.append(source_update_tag)
if not sync_needs and job.project.scm_type:
# see if we need a sync because of presence of roles
galaxy_req_path = os.path.join(project_path, 'roles', 'requirements.yml')
if os.path.exists(galaxy_req_path):
logger.debug('Running project sync for {} because of galaxy role requirements.'.format(job.log_format))
sync_needs.append('install_roles')
galaxy_collections_req_path = os.path.join(project_path, 'collections', 'requirements.yml') cache_id = str(job.project.last_job_id) # content cache - for roles and collections
if os.path.exists(galaxy_collections_req_path): has_cache = os.path.exists(os.path.join(job.project.get_cache_path(), cache_id))
logger.debug('Running project sync for {} because of galaxy collections requirements.'.format(job.log_format)) # Galaxy requirements are not supported for manual projects
sync_needs.append('install_collections') if job.project.scm_type and ((not has_cache) or branch_override):
sync_needs.extend(['install_roles', 'install_collections'])
if sync_needs: if sync_needs:
pu_ig = job.instance_group pu_ig = job.instance_group
@@ -1920,7 +1908,7 @@ class RunJob(BaseTask):
execution_node=pu_en, execution_node=pu_en,
celery_task_id=job.celery_task_id celery_task_id=job.celery_task_id
) )
if job.scm_branch and job.scm_branch != job.project.scm_branch: if branch_override:
sync_metafields['scm_branch'] = job.scm_branch sync_metafields['scm_branch'] = job.scm_branch
if 'update_' not in sync_metafields['job_tags']: if 'update_' not in sync_metafields['job_tags']:
sync_metafields['scm_revision'] = job_revision sync_metafields['scm_revision'] = job_revision
@@ -1952,10 +1940,7 @@ class RunJob(BaseTask):
if job_revision: if job_revision:
job = self.update_model(job.pk, scm_revision=job_revision) job = self.update_model(job.pk, scm_revision=job_revision)
# Project update does not copy the folder, so copy here # Project update does not copy the folder, so copy here
RunProjectUpdate.make_local_copy( RunProjectUpdate.make_local_copy(job.project, private_data_dir, scm_revision=job_revision)
project_path, os.path.join(private_data_dir, 'project'),
job.project.scm_type, job_revision
)
if job.inventory.kind == 'smart': if job.inventory.kind == 'smart':
# cache smart inventory memberships so that the host_filter query is not # cache smart inventory memberships so that the host_filter query is not
@@ -1995,10 +1980,7 @@ class RunProjectUpdate(BaseTask):
@property @property
def proot_show_paths(self): def proot_show_paths(self):
show_paths = [settings.PROJECTS_ROOT] return [settings.PROJECTS_ROOT]
if self.job_private_data_dir:
show_paths.append(self.job_private_data_dir)
return show_paths
def __init__(self, *args, job_private_data_dir=None, **kwargs): def __init__(self, *args, job_private_data_dir=None, **kwargs):
super(RunProjectUpdate, self).__init__(*args, **kwargs) super(RunProjectUpdate, self).__init__(*args, **kwargs)
@@ -2165,8 +2147,7 @@ class RunProjectUpdate(BaseTask):
extra_vars.update(extra_vars_new) extra_vars.update(extra_vars_new)
scm_branch = project_update.scm_branch scm_branch = project_update.scm_branch
branch_override = bool(scm_branch and project_update.scm_branch != project_update.project.scm_branch) if project_update.job_type == 'run' and (not project_update.branch_override):
if project_update.job_type == 'run' and (not branch_override):
if project_update.project.scm_revision: if project_update.project.scm_revision:
scm_branch = project_update.project.scm_revision scm_branch = project_update.project.scm_revision
elif not scm_branch: elif not scm_branch:
@@ -2174,14 +2155,15 @@ class RunProjectUpdate(BaseTask):
elif not scm_branch: elif not scm_branch:
scm_branch = {'hg': 'tip'}.get(project_update.scm_type, 'HEAD') scm_branch = {'hg': 'tip'}.get(project_update.scm_type, 'HEAD')
extra_vars.update({ extra_vars.update({
'project_path': project_update.get_project_path(check_if_exists=False), 'projects_root': settings.PROJECTS_ROOT.rstrip('/'),
'local_path': os.path.basename(project_update.project.local_path),
'project_path': project_update.get_project_path(check_if_exists=False), # deprecated
'insights_url': settings.INSIGHTS_URL_BASE, 'insights_url': settings.INSIGHTS_URL_BASE,
'awx_license_type': get_license(show_key=False).get('license_type', 'UNLICENSED'), 'awx_license_type': get_license(show_key=False).get('license_type', 'UNLICENSED'),
'awx_version': get_awx_version(), 'awx_version': get_awx_version(),
'scm_url': scm_url, 'scm_url': scm_url,
'scm_branch': scm_branch, 'scm_branch': scm_branch,
'scm_clean': project_update.scm_clean, 'scm_clean': project_update.scm_clean,
'project_cache': project_update.get_cache_path(),
'roles_enabled': settings.AWX_ROLES_ENABLED, 'roles_enabled': settings.AWX_ROLES_ENABLED,
'collections_enabled': settings.AWX_COLLECTIONS_ENABLED, 'collections_enabled': settings.AWX_COLLECTIONS_ENABLED,
}) })
@@ -2323,8 +2305,7 @@ class RunProjectUpdate(BaseTask):
os.mkdir(settings.PROJECTS_ROOT) os.mkdir(settings.PROJECTS_ROOT)
self.acquire_lock(instance) self.acquire_lock(instance)
self.original_branch = None self.original_branch = None
if (instance.scm_type == 'git' and instance.job_type == 'run' and instance.project and if instance.scm_type == 'git' and instance.branch_override:
instance.scm_branch != instance.project.scm_branch):
project_path = instance.project.get_project_path(check_if_exists=False) project_path = instance.project.get_project_path(check_if_exists=False)
if os.path.exists(project_path): if os.path.exists(project_path):
git_repo = git.Repo(project_path) git_repo = git.Repo(project_path)
@@ -2332,13 +2313,20 @@ class RunProjectUpdate(BaseTask):
self.original_branch = git_repo.head.commit self.original_branch = git_repo.head.commit
else: else:
self.original_branch = git_repo.active_branch self.original_branch = git_repo.active_branch
stage_path = os.path.join(instance.get_cache_path(), 'stage')
if os.path.exists(stage_path):
logger.warning('{0} cache staging area unexpectedly existed before update.')
shutil.rmtree(stage_path)
# Important - the presence of an empty cache will indicate that a given
# project revision did not have any roles or collections
os.makedirs(stage_path)
def clear_project_cache(self, instance, revision): @staticmethod
cache_dir = instance.get_cache_path() def clear_project_cache(cache_dir, keep_value):
if os.path.isdir(cache_dir): if os.path.isdir(cache_dir):
for entry in os.listdir(cache_dir): for entry in os.listdir(cache_dir):
old_path = os.path.join(cache_dir, entry) old_path = os.path.join(cache_dir, entry)
if entry != revision: if entry not in (keep_value, 'stage'):
# invalidate, then delete # invalidate, then delete
new_path = os.path.join(cache_dir,'.~~delete~~' + entry) new_path = os.path.join(cache_dir,'.~~delete~~' + entry)
try: try:
@@ -2348,16 +2336,27 @@ class RunProjectUpdate(BaseTask):
logger.warning(f"Could not remove cache directory {old_path}") logger.warning(f"Could not remove cache directory {old_path}")
@staticmethod @staticmethod
def make_local_copy(project_path, destination_folder, scm_type, scm_revision): def make_local_copy(p, job_private_data_dir, scm_revision=None):
if scm_type == 'git': """Copy project content (roles and collections) to a job private_data_dir
:param object p: Either a project or a project update
:param str job_private_data_dir: The root of the target ansible-runner folder
:param str scm_revision: For branch_override cases, the git revision to copy
"""
project_path = p.get_project_path(check_if_exists=False)
destination_folder = os.path.join(job_private_data_dir, 'project')
if not scm_revision:
scm_revision = p.scm_revision
if p.scm_type == 'git':
git_repo = git.Repo(project_path) git_repo = git.Repo(project_path)
if not os.path.exists(destination_folder): if not os.path.exists(destination_folder):
os.mkdir(destination_folder, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC) os.mkdir(destination_folder, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
tmp_branch_name = 'awx_internal/{}'.format(uuid4()) tmp_branch_name = 'awx_internal/{}'.format(uuid4())
# always clone based on specific job revision # always clone based on specific job revision
if not scm_revision: if not p.scm_revision:
raise RuntimeError('Unexpectedly could not determine a revision to run from project.') raise RuntimeError('Unexpectedly could not determine a revision to run from project.')
source_branch = git_repo.create_head(tmp_branch_name, scm_revision) source_branch = git_repo.create_head(tmp_branch_name, p.scm_revision)
# git clone must take file:// syntax for source repo or else options like depth will be ignored # git clone must take file:// syntax for source repo or else options like depth will be ignored
source_as_uri = Path(project_path).as_uri() source_as_uri = Path(project_path).as_uri()
git.Repo.clone_from( git.Repo.clone_from(
@@ -2376,20 +2375,48 @@ class RunProjectUpdate(BaseTask):
else: else:
copy_tree(project_path, destination_folder, preserve_symlinks=1) copy_tree(project_path, destination_folder, preserve_symlinks=1)
# copy over the roles and collection cache to job folder
cache_path = os.path.join(p.get_cache_path(), p.cache_id)
subfolders = []
if settings.AWX_COLLECTIONS_ENABLED:
subfolders.append('requirements_collections')
if settings.AWX_ROLES_ENABLED:
subfolders.append('requirements_roles')
for subfolder in subfolders:
cache_subpath = os.path.join(cache_path, subfolder)
if os.path.exists(cache_subpath):
dest_subpath = os.path.join(job_private_data_dir, subfolder)
copy_tree(cache_subpath, dest_subpath, preserve_symlinks=1)
logger.debug('{0} {1} prepared {2} from cache'.format(type(p).__name__, p.pk, dest_subpath))
def post_run_hook(self, instance, status): def post_run_hook(self, instance, status):
# To avoid hangs, very important to release lock even if errors happen here # To avoid hangs, very important to release lock even if errors happen here
try: try:
if self.playbook_new_revision: if self.playbook_new_revision:
self.clear_project_cache(instance, self.playbook_new_revision)
instance.scm_revision = self.playbook_new_revision instance.scm_revision = self.playbook_new_revision
instance.save(update_fields=['scm_revision']) instance.save(update_fields=['scm_revision'])
# Roles and collection folders copy to durable cache
base_path = instance.get_cache_path()
stage_path = os.path.join(base_path, 'stage')
if status == 'successful' and 'install_' in instance.job_tags:
# Clear other caches before saving this one, and if branch is overridden
# do not clear cache for main branch, but do clear it for other branches
self.clear_project_cache(base_path, keep_value=str(instance.project.last_job_id))
cache_path = os.path.join(base_path, instance.cache_id)
if os.path.exists(stage_path):
if os.path.exists(cache_path):
logger.warning('Rewriting cache at {0}, performance may suffer'.format(cache_path))
shutil.rmtree(cache_path)
os.rename(stage_path, cache_path)
logger.debug('{0} wrote to cache at {1}'.format(instance.log_format, cache_path))
elif os.path.exists(stage_path):
shutil.rmtree(stage_path) # cannot trust content update produced
if self.job_private_data_dir: if self.job_private_data_dir:
# copy project folder before resetting to default branch # copy project folder before resetting to default branch
# because some git-tree-specific resources (like submodules) might matter # because some git-tree-specific resources (like submodules) might matter
self.make_local_copy( self.make_local_copy(instance, self.job_private_data_dir)
instance.get_project_path(check_if_exists=False), os.path.join(self.job_private_data_dir, 'project'),
instance.scm_type, instance.scm_revision
)
if self.original_branch: if self.original_branch:
# for git project syncs, non-default branches can be problems # for git project syncs, non-default branches can be problems
# restore to branch the repo was on before this run # restore to branch the repo was on before this run
@@ -2642,13 +2669,22 @@ class RunInventoryUpdate(BaseTask):
source_project = None source_project = None
if inventory_update.inventory_source: if inventory_update.inventory_source:
source_project = inventory_update.inventory_source.source_project source_project = inventory_update.inventory_source.source_project
if (inventory_update.source=='scm' and inventory_update.launch_type!='scm' and source_project): if (inventory_update.source=='scm' and inventory_update.launch_type!='scm' and
# In project sync, pulling galaxy roles is not needed source_project and source_project.scm_type): # never ever update manual projects
# Check if the content cache exists, so that we do not unnecessarily re-download roles
sync_needs = ['update_{}'.format(source_project.scm_type)]
cache_id = str(source_project.last_job_id) # content cache id for roles and collections
has_cache = os.path.exists(os.path.join(source_project.get_cache_path(), cache_id))
# Galaxy requirements are not supported for manual projects
if not has_cache:
sync_needs.extend(['install_roles', 'install_collections'])
local_project_sync = source_project.create_project_update( local_project_sync = source_project.create_project_update(
_eager_fields=dict( _eager_fields=dict(
launch_type="sync", launch_type="sync",
job_type='run', job_type='run',
job_tags='update_{},install_collections'.format(source_project.scm_type), # roles are never valid for inventory job_tags=','.join(sync_needs),
status='running', status='running',
execution_node=inventory_update.execution_node, execution_node=inventory_update.execution_node,
instance_group = inventory_update.instance_group, instance_group = inventory_update.instance_group,
@@ -2672,11 +2708,7 @@ class RunInventoryUpdate(BaseTask):
raise raise
elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project: elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project:
# This follows update, not sync, so make copy here # This follows update, not sync, so make copy here
project_path = source_project.get_project_path(check_if_exists=False) RunProjectUpdate.make_local_copy(source_project, private_data_dir)
RunProjectUpdate.make_local_copy(
project_path, os.path.join(private_data_dir, 'project'),
source_project.scm_type, source_project.scm_revision
)
@task(queue=get_local_queuename) @task(queue=get_local_queuename)

View File

@@ -54,7 +54,9 @@ def test_no_changing_overwrite_behavior_if_used(post, patch, organization, admin
data={ data={
'name': 'fooo', 'name': 'fooo',
'organization': organization.id, 'organization': organization.id,
'allow_override': True 'allow_override': True,
'scm_type': 'git',
'scm_url': 'https://github.com/ansible/test-playbooks.git'
}, },
user=admin_user, user=admin_user,
expect=201 expect=201
@@ -83,7 +85,9 @@ def test_changing_overwrite_behavior_okay_if_not_used(post, patch, organization,
data={ data={
'name': 'fooo', 'name': 'fooo',
'organization': organization.id, 'organization': organization.id,
'allow_override': True 'allow_override': True,
'scm_type': 'git',
'scm_url': 'https://github.com/ansible/test-playbooks.git'
}, },
user=admin_user, user=admin_user,
expect=201 expect=201

View File

@@ -145,7 +145,6 @@ def project(instance, organization):
description="test-proj-desc", description="test-proj-desc",
organization=organization, organization=organization,
playbook_files=['helloworld.yml', 'alt-helloworld.yml'], playbook_files=['helloworld.yml', 'alt-helloworld.yml'],
local_path='_92__test_proj',
scm_revision='1234567890123456789012345678901234567890', scm_revision='1234567890123456789012345678901234567890',
scm_url='localhost', scm_url='localhost',
scm_type='git' scm_type='git'

View File

@@ -169,7 +169,8 @@ class TestSCMUpdateFeatures:
inventory_update = InventoryUpdate( inventory_update = InventoryUpdate(
inventory_source=scm_inventory_source, inventory_source=scm_inventory_source,
source_path=scm_inventory_source.source_path) source_path=scm_inventory_source.source_path)
assert inventory_update.get_actual_source_path().endswith('_92__test_proj/inventory_file') p = scm_inventory_source.source_project
assert inventory_update.get_actual_source_path().endswith(f'_{p.id}__test_proj/inventory_file')
def test_no_unwanted_updates(self, scm_inventory_source): def test_no_unwanted_updates(self, scm_inventory_source):
# Changing the non-sensitive fields should not trigger update # Changing the non-sensitive fields should not trigger update

View File

@@ -34,6 +34,18 @@ def test_sensitive_change_triggers_update(project):
mock_update.assert_called_once_with() mock_update.assert_called_once_with()
@pytest.mark.django_db
def test_local_path_autoset(organization):
with mock.patch.object(Project, "update"):
p = Project.objects.create(
name="test-proj",
organization=organization,
scm_url='localhost',
scm_type='git'
)
assert p.local_path == f'_{p.id}__test_proj'
@pytest.mark.django_db @pytest.mark.django_db
def test_foreign_key_change_changes_modified_by(project, organization): def test_foreign_key_change_changes_modified_by(project, organization):
assert project._get_fields_snapshot()['organization_id'] == organization.id assert project._get_fields_snapshot()['organization_id'] == organization.id

View File

@@ -29,8 +29,8 @@ def team_project_list(organization_factory):
@pytest.mark.django_db @pytest.mark.django_db
def test_get_project_path(project): def test_get_project_path(project):
# Test combining projects root with project local path # Test combining projects root with project local path
with mock.patch('awx.main.models.projects.settings.PROJECTS_ROOT', '/var/lib/awx'): with mock.patch('awx.main.models.projects.settings.PROJECTS_ROOT', '/var/lib/foo'):
assert project.get_project_path(check_if_exists=False) == '/var/lib/awx/_92__test_proj' assert project.get_project_path(check_if_exists=False) == f'/var/lib/foo/_{project.id}__test_proj'
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -30,7 +30,7 @@ class TestDependentInventoryUpdate:
def test_dependent_inventory_updates_is_called(self, scm_inventory_source, scm_revision_file): def test_dependent_inventory_updates_is_called(self, scm_inventory_source, scm_revision_file):
task = RunProjectUpdate() task = RunProjectUpdate()
task.revision_path = scm_revision_file task.revision_path = scm_revision_file
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project) proj_update = scm_inventory_source.source_project.create_project_update()
with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck: with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck:
with mock.patch.object(RunProjectUpdate, 'release_lock'): with mock.patch.object(RunProjectUpdate, 'release_lock'):
task.post_run_hook(proj_update, 'successful') task.post_run_hook(proj_update, 'successful')
@@ -39,7 +39,7 @@ class TestDependentInventoryUpdate:
def test_no_unwanted_dependent_inventory_updates(self, project, scm_revision_file): def test_no_unwanted_dependent_inventory_updates(self, project, scm_revision_file):
task = RunProjectUpdate() task = RunProjectUpdate()
task.revision_path = scm_revision_file task.revision_path = scm_revision_file
proj_update = ProjectUpdate.objects.create(project=project) proj_update = project.create_project_update()
with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck: with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck:
with mock.patch.object(RunProjectUpdate, 'release_lock'): with mock.patch.object(RunProjectUpdate, 'release_lock'):
task.post_run_hook(proj_update, 'successful') task.post_run_hook(proj_update, 'successful')

View File

@@ -61,7 +61,10 @@ def patch_Job():
@pytest.fixture @pytest.fixture
def job(): def job():
return Job(pk=1, id=1, project=Project(), inventory=Inventory(), job_template=JobTemplate(id=1, name='foo')) return Job(
pk=1, id=1,
project=Project(local_path='/projects/_23_foo'),
inventory=Inventory(), job_template=JobTemplate(id=1, name='foo'))
@pytest.fixture @pytest.fixture
@@ -406,7 +409,9 @@ class TestExtraVarSanitation(TestJobExecution):
class TestGenericRun(): class TestGenericRun():
def test_generic_failure(self, patch_Job): def test_generic_failure(self, patch_Job):
job = Job(status='running', inventory=Inventory(), project=Project()) job = Job(
status='running', inventory=Inventory(),
project=Project(local_path='/projects/_23_foo'))
job.websocket_emit_status = mock.Mock() job.websocket_emit_status = mock.Mock()
task = tasks.RunJob() task = tasks.RunJob()

View File

@@ -1,7 +1,9 @@
--- ---
# The following variables will be set by the runner of this playbook: # The following variables will be set by the runner of this playbook:
# project_path: PROJECTS_DIR/_local_path_ # projects_root: Global location for caching project checkouts and roles and collections
# project_cache: CACHE_DIR/.__awx_cache/_local_path_ # should not have trailing slash on end
# local_path: Path within projects_root to use for this project
# project_path: projects_root/local_path
# scm_url: https://server/repo # scm_url: https://server/repo
# insights_url: Insights service URL (from configuration) # insights_url: Insights service URL (from configuration)
# scm_branch: branch/tag/revision (HEAD if unset) # scm_branch: branch/tag/revision (HEAD if unset)
@@ -30,13 +32,6 @@
tags: tags:
- delete - delete
- name: delete project cache directory before update
file:
path: "{{project_cache|quote}}"
state: absent
tags:
- delete
- block: - block:
- name: update project using git - name: update project using git
git: git:
@@ -117,12 +112,11 @@
- update_svn - update_svn
- update_insights - update_insights
- name: Set content cache location - hosts: localhost
set_fact: gather_facts: false
cache_dir: "{{ project_cache }}/{{ scm_version|default(scm_branch) }}" connection: local
tags: name: Install content with ansible-galaxy command if necessary
- install_collections tasks:
- install_roles
- block: - block:
- name: detect requirements.yml - name: detect requirements.yml
@@ -131,7 +125,10 @@
register: doesRequirementsExist register: doesRequirementsExist
- name: fetch galaxy roles from requirements.yml - name: fetch galaxy roles from requirements.yml
command: ansible-galaxy role install {{ '--force' if roles_destination is not defined else '' }} -r roles/requirements.yml -p {{ cache_dir }}/requirements_roles {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} command: >
ansible-galaxy role install -r roles/requirements.yml
--roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args: args:
chdir: "{{project_path|quote}}" chdir: "{{project_path|quote}}"
register: galaxy_result register: galaxy_result
@@ -141,12 +138,6 @@
ANSIBLE_FORCE_COLOR: false ANSIBLE_FORCE_COLOR: false
GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no" GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no"
- name: populate job directory with needed roles
synchronize:
src: "{{ cache_dir }}/requirements_roles/"
dest: "{{ roles_destination }}"
when: roles_destination is defined and doesRequirementsExist.stat.exists
when: roles_enabled|bool when: roles_enabled|bool
tags: tags:
- install_roles - install_roles
@@ -158,7 +149,10 @@
register: doesCollectionRequirementsExist register: doesCollectionRequirementsExist
- name: fetch galaxy collections from collections/requirements.yml - name: fetch galaxy collections from collections/requirements.yml
command: ansible-galaxy collection install {{ '--force' if collections_destination is not defined else '' }} -r collections/requirements.yml -p {{ cache_dir }}/requirements_collections {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} command: >
ansible-galaxy collection install -r collections/requirements.yml
--collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args: args:
chdir: "{{project_path|quote}}" chdir: "{{project_path|quote}}"
register: galaxy_collection_result register: galaxy_collection_result
@@ -166,15 +160,9 @@
changed_when: "'Installing ' in galaxy_collection_result.stdout" changed_when: "'Installing ' in galaxy_collection_result.stdout"
environment: environment:
ANSIBLE_FORCE_COLOR: false ANSIBLE_FORCE_COLOR: false
ANSIBLE_COLLECTIONS_PATHS: "{{ cache_dir }}/requirements_collections" ANSIBLE_COLLECTIONS_PATHS: "{{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections"
GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no" GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no"
- name: populate job directory with needed collections
synchronize:
src: "{{ cache_dir }}/requirements_collections/"
dest: "{{ collections_destination }}"
when: collections_destination is defined and doesCollectionRequirementsExist.stat.exists
when: when:
- "ansible_version.full is version_compare('2.8', '>=')" - "ansible_version.full is version_compare('2.8', '>=')"
- collections_enabled|bool - collections_enabled|bool

View File

@@ -4,15 +4,18 @@ AWX supports the use of Ansible Collections. This section will give ways to use
### Project Collections Requirements ### Project Collections Requirements
If you specify a Collections requirements file in SCM at `collections/requirements.yml`, If you specify a collections requirements file in SCM at `collections/requirements.yml`,
then AWX will install Collections from that file in the implicit project sync then AWX will install collections from that file to a special cache folder in project updates.
before a job run. The invocation looks like: Before a job runs, the roles and/or collections will be copied from the special
cache folder to the job temporary folder.
The invocation looks like:
``` ```
ansible-galaxy collection install -r requirements.yml -p <job tmp location>/requirements_collections ansible-galaxy collection install -r requirements.yml -p <project cache location>/requirements_collections
``` ```
Example of the resultant `tmp` directory where job is running: Example of the resultant job `tmp` directory where job is running:
``` ```
├── project ├── project
@@ -20,7 +23,7 @@ Example of the resultant `tmp` directory where job is running:
│   └── debug.yml │   └── debug.yml
├── requirements_collections ├── requirements_collections
│   └── ansible_collections │   └── ansible_collections
│   └── username │   └── collection_namespace
│   └── collection_name │   └── collection_name
│   ├── FILES.json │   ├── FILES.json
│   ├── MANIFEST.json │   ├── MANIFEST.json
@@ -53,6 +56,33 @@ Example of the resultant `tmp` directory where job is running:
``` ```
### Cache Folder Mechanics
Every time a project is updated as a "check" job
(via `/api/v2/projects/N/update/` or by a schedule, workflow, etc.),
the roles and collections are downloaded and saved to the project's content cache.
In other words, the cache is invalidated every time a project is updated.
That means that the `ansible-galaxy` commands are ran to download content
even if the project revision does not change in the course of the update.
Project updates all initially target a staging directory at a path like:
```
/var/lib/awx/projects/.__awx_cache/_42__project_name/stage
```
After the update finishes, the task logic will decide what id to associate
with the content downloaded.
Then the folder will be renamed from "stage" to the cache id.
For instance, if the cache id is determined to be 63:
```
/var/lib/awx/projects/.__awx_cache/_42__project_name/63
```
The cache may be updated by project syncs (the "run" type) which happen before
job runs. It will populate the cache id set by the last "check" type update.
### Galaxy Server Selection ### Galaxy Server Selection
Ansible core default settings will download collections from the public Ansible core default settings will download collections from the public