Create test for using manual & file projects (#15754)

* Create test for using a manual project

* Chang default project factory to git, remove project files monkeypatch

* skip update of factory project

* Initial file scaffolding for feature

* Fill in galaxy and names

* Add README, describe project folders and dependencies
This commit is contained in:
Alan Rominger 2025-01-20 17:06:15 -05:00 committed by GitHub
parent 46403e4312
commit c43dfde45a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 359 additions and 24 deletions

View File

@ -0,0 +1,41 @@
# Project data for live tests
Each folder in this directory is usable as source for a project or role or collection,
which is used in tests, particularly the "awx/main/tests/live" tests.
Although these are not git repositories, test fixtures will make copies,
and in the coppied folders, run `git init` type commands, turning them into
git repos. This is done in the locations
- `/var/lib/awx/projects`
- `/tmp/live_tests`
These can then be referenced for manual projects or git via the `file://` protocol.
## debug
This is the simplest possible case with 1 playbook with 1 debug task.
## with_requirements
This has a playbook that runs a task that uses a role.
The role project is referenced in the `roles/requirements.yml` file.
### role_requirement
This is the source for the role that the `with_requirements` project uses.
## test_host_query
This has a playbook that runs a task from a custom collection module which
is registered for the host query feature.
The collection is referenced in its `collections/requirements.yml` file.
### host_query
This can act as source code for a collection that enables host/event querying.
It has a `meta/event_query.yml` file, which may provide you an example of how
to implement this in your own collection.

View File

@ -0,0 +1,6 @@
---
- hosts: all
gather_facts: false
connection: local
tasks:
- debug: msg='hello'

View File

@ -0,0 +1,19 @@
---
authors:
- AWX Project Contributors <awx-project@googlegroups.com>
dependencies: {}
description: Indirect host counting example repo. Not for use in production.
documentation: https://github.com/ansible/awx
homepage: https://github.com/ansible/awx
issues: https://github.com/ansible/awx
license:
- GPL-3.0-or-later
name: query
namespace: demo
readme: README.md
repository: https://github.com/ansible/awx
tags:
- demo
- testing
- host_counting
version: 0.0.1

View File

@ -0,0 +1,4 @@
---
{
"demo.query.example": ""
}

View File

@ -0,0 +1,74 @@
#!/usr/bin/python
# Same licensing as AWX
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: example
short_description: Module for specific live tests
version_added: "2.0.0"
description: This module is part of a test collection in local source.
options:
host_name:
description: Name to return as the host name.
required: false
type: str
author:
- AWX Live Tests
'''
EXAMPLES = r'''
- name: Test with defaults
demo.query.example:
- name: Test with custom host name
demo.query.example:
host_name: foo_host
'''
RETURN = r'''
direct_host_name:
description: The name of the host, this will be collected with the feature.
type: str
returned: always
sample: 'foo_host'
'''
from ansible.module_utils.basic import AnsibleModule
def run_module():
module_args = dict(
host_name=dict(type='str', required=False, default='foo_host_default'),
)
result = dict(
changed=False,
other_data='sample_string',
)
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
if module.check_mode:
module.exit_json(**result)
result['direct_host_name'] = module.params['host_name']
result['nested_host_name'] = {'host_name': module.params['host_name']}
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,19 @@
---
galaxy_info:
author: "For Test"
company: AWX
license: MIT
min_ansible_version: 1.4
platforms:
- name: EL
versions:
- 8
- 9
- name: Fedora
versions:
- 39
- 40
- 41
categories:
- stuff
dependencies: []

View File

@ -0,0 +1,4 @@
---
- name: debug variable
debug:
msg: "1234567890"

View File

@ -0,0 +1,5 @@
---
collections:
- name: 'file:///tmp/live_tests/host_query'
type: git
version: devel

View File

@ -0,0 +1,8 @@
---
- hosts: all
gather_facts: false
connection: local
tasks:
- demo.query.example:
register: result
- debug: var=result

View File

@ -0,0 +1,3 @@
---
- name: role_requirement
src: git+file:///tmp/live_tests/role_requirement

View File

@ -0,0 +1,7 @@
---
- hosts: all
connection: local
gather_facts: false
tasks:
- include_role:
name: role_requirement

View File

@ -99,11 +99,19 @@ def mk_user(name, is_superuser=False, organization=None, team=None, persisted=Tr
def mk_project(name, organization=None, description=None, persisted=True):
description = description or '{}-description'.format(name)
project = Project(name=name, description=description, playbook_files=['helloworld.yml', 'alt-helloworld.yml'])
project = Project(
name=name,
description=description,
playbook_files=['helloworld.yml', 'alt-helloworld.yml'],
scm_type='git',
scm_url='https://foo.invalid',
scm_revision='1234567890123456789012345678901234567890',
scm_update_on_launch=False,
)
if organization is not None:
project.organization = organization
if persisted:
project.save()
project.save(skip_update=True)
return project

View File

@ -18,7 +18,7 @@ class TestUnifiedOrganization:
def data_for_model(self, model, orm_style=False):
data = {'name': 'foo', 'organization': None}
if model == 'JobTemplate':
proj = models.Project.objects.create(name="test-proj", playbook_files=['helloworld.yml'])
proj = models.Project.objects.create(name="test-proj", playbook_files=['helloworld.yml'], scm_type='git', scm_url='https://foo.invalid')
if orm_style:
data['project_id'] = proj.id
else:

View File

@ -114,20 +114,6 @@ def team_member(user, team):
return ret
@pytest.fixture(scope="session", autouse=True)
def project_playbooks():
"""
Return playbook_files as playbooks for manual projects when testing.
"""
class PlaybooksMock(mock.PropertyMock):
def __get__(self, obj, obj_type):
return obj.playbook_files
mocked = mock.patch.object(Project, 'playbooks', new_callable=PlaybooksMock)
mocked.start()
@pytest.fixture
def run_computed_fields_right_away(request):
def run_me(inventory_id):

View File

@ -335,7 +335,7 @@ def test_team_project_list(get, team_project_list):
@pytest.mark.parametrize("u,expected_status_code", [('rando', 403), ('org_member', 403), ('org_admin', 201), ('admin', 201)])
@pytest.mark.django_db()
@pytest.mark.django_db
def test_create_project(post, organization, org_admin, org_member, admin, rando, u, expected_status_code):
if u == 'rando':
u = rando
@ -353,11 +353,12 @@ def test_create_project(post, organization, org_admin, org_member, admin, rando,
'organization': organization.id,
},
u,
expect=expected_status_code,
)
print(result.data)
assert result.status_code == expected_status_code
if expected_status_code == 201:
assert Project.objects.filter(name='Project', organization=organization).exists()
elif expected_status_code == 403:
assert 'do not have permission' in str(result.data['detail'])
@pytest.mark.django_db

View File

@ -7,7 +7,7 @@ import pytest
from awx.main.tests.functional.conftest import * # noqa
from awx.main.tests.conftest import load_all_credentials # noqa: F401; pylint: disable=unused-import
from awx.main.models import Organization
from awx.main.models import Organization, Inventory
def wait_to_leave_status(job, status, timeout=30, sleep_time=0.1):
@ -25,12 +25,26 @@ def wait_to_leave_status(job, status, timeout=30, sleep_time=0.1):
raise RuntimeError(f'Job failed to exit {status} in {timeout} seconds. job_explanation={job.job_explanation} tb={job.result_traceback}')
def wait_for_events(uj, timeout=2):
start = time.time()
while uj.event_processing_finished is False:
time.sleep(0.2)
uj.refresh_from_db()
if time.time() - start > timeout:
break
def unified_job_stdout(uj):
wait_for_events(uj)
return '\n'.join([event.stdout for event in uj.get_event_queryset().order_by('created')])
def wait_for_job(job, final_status='successful', running_timeout=800):
wait_to_leave_status(job, 'pending')
wait_to_leave_status(job, 'waiting')
wait_to_leave_status(job, 'running', timeout=running_timeout)
assert job.status == final_status, f'Job was not successful id={job.id} status={job.status}'
assert job.status == final_status, f'Job was not successful id={job.id} status={job.status} tb={job.result_traceback} output=\n{unified_job_stdout(job)}'
@pytest.fixture(scope='session')
@ -39,3 +53,9 @@ def default_org():
if org is None:
raise Exception('Tests expect Default org to already be created and it is not')
return org
@pytest.fixture(scope='session')
def demo_inv(default_org):
inventory, _ = Inventory.objects.get_or_create(name='Demo Inventory', defaults={'organization': default_org})
return inventory

View File

@ -0,0 +1,115 @@
import pytest
import os
import shutil
import tempfile
import subprocess
from django.conf import settings
from awx.api.versioning import reverse
from awx.main.tests import data
from awx.main.models import Project, JobTemplate
from awx.main.tests.live.tests.conftest import wait_for_job
PROJ_DATA = os.path.join(os.path.dirname(data.__file__), 'projects')
def _copy_folders(source_path, dest_path, clear=False):
"folder-by-folder, copy dirs in the source root dir to the destination root dir"
for dirname in os.listdir(source_path):
source_dir = os.path.join(source_path, dirname)
expected_dir = os.path.join(dest_path, dirname)
if clear and os.path.exists(expected_dir):
shutil.rmtree(expected_dir)
if (not os.path.isdir(source_dir)) or os.path.exists(expected_dir):
continue
shutil.copytree(source_dir, expected_dir)
@pytest.fixture(scope='session')
def copy_project_folders():
proj_root = settings.PROJECTS_ROOT
if not os.path.exists(proj_root):
os.mkdir(proj_root)
_copy_folders(PROJ_DATA, proj_root, clear=True)
GIT_COMMANDS = (
'git config --global init.defaultBranch devel; '
'git init; '
'git config user.email jenkins@ansible.com; '
'git config user.name DoneByTest; '
'git add .; '
'git commit -m "initial commit"'
)
@pytest.fixture(scope='session')
def live_tmp_folder():
path = os.path.join(tempfile.gettempdir(), 'live_tests')
if os.path.exists(path):
shutil.rmtree(path)
os.mkdir(path)
_copy_folders(PROJ_DATA, path)
for dirname in os.listdir(path):
source_dir = os.path.join(path, dirname)
subprocess.run(GIT_COMMANDS, cwd=source_dir, shell=True)
if path not in settings.AWX_ISOLATION_SHOW_PATHS:
settings.AWX_ISOLATION_SHOW_PATHS = settings.AWX_ISOLATION_SHOW_PATHS + [path]
return path
@pytest.fixture
def run_job_from_playbook(default_org, demo_inv, post, admin):
def _rf(test_name, playbook, local_path=None, scm_url=None):
project_name = f'{test_name} project'
jt_name = f'{test_name} JT: {playbook}'
old_proj = Project.objects.filter(name=project_name).first()
if old_proj:
old_proj.delete()
old_jt = JobTemplate.objects.filter(name=jt_name).first()
if old_jt:
old_jt.delete()
proj_kwargs = {'name': project_name, 'organization': default_org.id}
if local_path:
# manual path
proj_kwargs['scm_type'] = ''
proj_kwargs['local_path'] = local_path
elif scm_url:
proj_kwargs['scm_type'] = 'git'
proj_kwargs['scm_url'] = scm_url
else:
raise RuntimeError('Need to provide scm_url or local_path')
result = post(
reverse('api:project_list'),
proj_kwargs,
admin,
expect=201,
)
proj = Project.objects.get(id=result.data['id'])
if proj.current_job:
wait_for_job(proj.current_job)
assert proj.get_project_path()
assert playbook in proj.playbooks
result = post(
reverse('api:job_template_list'),
{'name': jt_name, 'project': proj.id, 'playbook': playbook, 'inventory': demo_inv.id},
admin,
expect=201,
)
jt = JobTemplate.objects.get(id=result.data['id'])
job = jt.create_unified_job()
job.signal_start()
wait_for_job(job)
assert job.status == 'successful'
return _rf

View File

@ -0,0 +1,2 @@
def test_git_file_project(live_tmp_folder, run_job_from_playbook):
run_job_from_playbook('test_git_file_project', 'debug.yml', scm_url=f'file://{live_tmp_folder}/debug')

View File

@ -0,0 +1,3 @@
def test_indirect_host_counting(live_tmp_folder, run_job_from_playbook):
run_job_from_playbook('test_indirect_host_counting', 'run_task.yml', scm_url=f'file://{live_tmp_folder}/test_host_query')
# TODO: add assertions that the host query data is populated

View File

@ -0,0 +1,2 @@
def test_manual_project(copy_project_folders, run_job_from_playbook):
run_job_from_playbook('test_manual_project', 'debug.yml', local_path='debug')

View File

@ -5,9 +5,9 @@ import pytest
from django.conf import settings
from awx.main.tests.live.tests.conftest import wait_for_job
from awx.main.tests.live.tests.conftest import wait_for_job, wait_for_events
from awx.main.models import Project, SystemJobTemplate
from awx.main.models import Project, SystemJobTemplate, Job
@pytest.fixture(scope='session')
@ -54,3 +54,11 @@ def test_cache_is_populated_after_cleanup_job(project_with_requirements):
# Now, we still have a populated cache
assert project_cache_is_populated(project_with_requirements)
def test_git_file_collection_requirement(live_tmp_folder, copy_project_folders, run_job_from_playbook):
# this behaves differently, as use_requirements.yml references only the folder, does not include the github name
run_job_from_playbook('test_git_file_collection_requirement', 'use_requirement.yml', scm_url=f'file://{live_tmp_folder}/with_requirements')
job = Job.objects.filter(name__icontains='test_git_file_collection_requirement').order_by('-created').first()
wait_for_events(job)
assert '1234567890' in job.job_events.filter(task='debug variable', event='runner_on_ok').first().stdout