Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Successfully test collection of event_query.yml data #15761

Merged
merged 2 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 37 additions & 16 deletions awx/main/tasks/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
from rest_framework.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _

# Django flags
from flags.state import flag_enabled

logger = logging.getLogger('awx.main.tasks.jobs')


Expand Down Expand Up @@ -439,20 +442,17 @@ def final_run_hook(self, instance, status, private_data_dir):
Hook for any steps to run after job/task is marked as complete.
"""
instance.log_lifecycle("finalize_run")
artifact_dir = os.path.join(private_data_dir, 'artifacts', str(self.instance.id))
collections_info = os.path.join(artifact_dir, 'collections.json')
ansible_version_file = os.path.join(artifact_dir, 'ansible_version.txt')

if os.path.exists(collections_info):
with open(collections_info) as ee_json_info:
ee_collections_info = json.loads(ee_json_info.read())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this mechanism already exists? Or was it unused so far?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It existed, but was broken with a particular ansible-runner version.

The design problem is that we can't honestly collect the event_query data without also indirectly collecting the collection list (which is what this did, but is broken). So it would be awfully neglectful to throw in the new loading logic right next to the old, broken, artifact file loading which is the same in form.

We still have a software coordination problem, which is that supposed there is a shared problem which will lead to ansible-runner developing a new solution, and that the collection listing this uses is a non-public API... but categorically, if there is no public API for the collection list, this sub-collection detail of the event_query can't be gathered in a kosher manner.

Let me jump to the conclusion - we finish up this approach and call it the "beta" version. It will be fully functional, but use non-public APIs. Then, we will have a TODO when the public API (new callback methods) are delivered from ansible-runner. We may need to have an import test / fallback to switch between the "beta" behavior and the accepted solution.

instance.installed_collections = ee_collections_info
instance.save(update_fields=['installed_collections'])
if os.path.exists(ansible_version_file):
with open(ansible_version_file) as ee_ansible_info:
ansible_version_info = ee_ansible_info.readline()
instance.ansible_version = ansible_version_info
instance.save(update_fields=['ansible_version'])
if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
artifact_dir = os.path.join(private_data_dir, 'artifacts', str(self.instance.id))
data_file_path = os.path.join(artifact_dir, 'ansible_data.json')

if os.path.exists(data_file_path):
with open(data_file_path) as f:
collected_data = json.loads(f.read())

instance.installed_collections = collected_data['installed_collections']
instance.ansible_version = collected_data['ansible_version']
instance.save(update_fields=['installed_collections', 'ansible_version'])

# Run task manager appropriately for speculative dependencies
if instance.unifiedjob_blocked_jobs.exists():
Expand Down Expand Up @@ -927,11 +927,16 @@ def build_env(self, job, private_data_dir, private_data_files=None):
if authorize:
env['ANSIBLE_NET_AUTH_PASS'] = network_cred.get_input('authorize_password', default='')

path_vars = (
path_vars = [
('ANSIBLE_COLLECTIONS_PATHS', 'collections_paths', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
('ANSIBLE_ROLES_PATH', 'roles_path', 'requirements_roles', '~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles'),
('ANSIBLE_COLLECTIONS_PATH', 'collections_path', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
)
]

if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
path_vars.append(
('ANSIBLE_CALLBACK_PLUGINS', 'callback_plugins', 'plugins_path', '~/.ansible/plugins:/plugins/callback:/usr/share/ansible/plugins/callback'),
)

config_values = read_ansible_config(os.path.join(private_data_dir, 'project'), list(map(lambda x: x[1], path_vars)))

Expand All @@ -948,6 +953,11 @@ def build_env(self, job, private_data_dir, private_data_files=None):
paths = [os.path.join(CONTAINER_ROOT, folder)] + paths
env[env_key] = os.pathsep.join(paths)

if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
env['ANSIBLE_CALLBACKS_ENABLED'] = 'indirect_instance_count'
if 'callbacks_enabled' in config_values:
env['ANSIBLE_CALLBACKS_ENABLED'] += ':' + config_values['callbacks_enabled']

return env

def build_args(self, job, private_data_dir, passwords):
Expand Down Expand Up @@ -1388,6 +1398,17 @@ def make_local_copy(project, job_private_data_dir):
shutil.copytree(cache_subpath, dest_subpath, symlinks=True)
logger.debug('{0} {1} prepared {2} from cache'.format(type(project).__name__, project.pk, dest_subpath))

if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
# copy the special callback (not stdout type) plugin to get list of collections
pdd_plugins_path = os.path.join(job_private_data_dir, 'plugins_path')
if not os.path.exists(pdd_plugins_path):
os.mkdir(pdd_plugins_path)
from awx.playbooks import library

plugin_file_source = os.path.join(library.__path__._path[0], 'indirect_instance_count.py')
plugin_file_dest = os.path.join(pdd_plugins_path, 'indirect_instance_count.py')
shutil.copyfile(plugin_file_source, plugin_file_dest)

def post_run_hook(self, instance, status):
super(RunProjectUpdate, self).post_run_hook(instance, status)
# To avoid hangs, very important to release lock even if errors happen here
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import yaml

from awx.main.tests.live.tests.conftest import wait_for_events

from awx.main.tasks.host_indirect import build_indirect_host_data
from awx.main.models import Job


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')
job = Job.objects.filter(name__icontains='test_indirect_host_counting').order_by('-created').first()
wait_for_events(job) # We must wait for events because system tasks iterate on job.job_events.filter(...)

# Data matches to awx/main/tests/data/projects/host_query/meta/event_query.yml
# this just does things in-line to be a more localized test for the immediate testing
event_query = {'demo.query.example': '{canonical_facts: {host_name: .direct_host_name}, facts: {device_type: .device_type}}'}
module_jq_str = '{canonical_facts: {host_name: .direct_host_name}, facts: {device_type: .device_type}}'
event_query = {'demo.query.example': module_jq_str}

# Run the task logic directly with local data
results = build_indirect_host_data(job, event_query)
Expand All @@ -18,3 +24,12 @@ def test_indirect_host_counting(live_tmp_folder, run_job_from_playbook):
# Asserts on data that will match to the input jq string from above
assert host_audit_entry.canonical_facts == {'host_name': 'foo_host_default'}
assert host_audit_entry.facts == {'device_type': 'Fake Host'}

# Test collection of data
assert 'demo.query' in job.installed_collections
assert 'host_query' in job.installed_collections['demo.query']
hq_text = job.installed_collections['demo.query']['host_query']
hq_data = yaml.safe_load(hq_text)
assert hq_data == {'demo.query.example': module_jq_str}

assert job.ansible_version
92 changes: 92 additions & 0 deletions awx/playbooks/library/indirect_instance_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# (C) 2012, Michael DeHaan, <[email protected]>
# (c) 2017 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function

__metaclass__ = type


DOCUMENTATION = '''
callback: host_query
type: notification
short_description: for demo of indirect host data and counting, this produces collection data
version_added: historical
description:
- Saves collection data to artifacts folder
requirements:
- Whitelist in configuration
- Set AWX_ISOLATED_DATA_DIR, AWX will do this
'''

import os
import json

from ansible.plugins.callback import CallbackBase


# NOTE: in Ansible 1.2 or later general logging is available without
# this plugin, just set ANSIBLE_LOG_PATH as an environment variable
# or log_path in the DEFAULTS section of your ansible configuration
# file. This callback is an example of per hosts logging for those
# that want it.


# Taken from https://github.com/ansible/ansible/blob/devel/lib/ansible/cli/galaxy.py#L1624

from ansible.cli.galaxy import with_collection_artifacts_manager
from ansible.release import __version__

from ansible.galaxy.collection import find_existing_collections
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: will have to wrap this in a try-except, not currently a public API. Do simple warning of some type if it doesn't work.

from ansible.utils.collection_loader import AnsibleCollectionConfig
import ansible.constants as C

from ansible.module_utils.common.text.converters import to_text


@with_collection_artifacts_manager
def list_collections(artifacts_manager=None):
artifacts_manager.require_build_metadata = False

default_collections_path = set(C.COLLECTIONS_PATHS)
collections_search_paths = default_collections_path | set(AnsibleCollectionConfig.collection_paths)
collections = list(find_existing_collections(list(collections_search_paths), artifacts_manager, dedupe=False))
return collections


class CallbackModule(CallbackBase):
"""
logs playbook results, per host, in /var/log/ansible/hosts
"""

CALLBACK_VERSION = 2.0
CALLBACK_TYPE = 'notification'
CALLBACK_NAME = 'indirect_instance_count'
CALLBACK_NEEDS_WHITELIST = True

TIME_FORMAT = "%b %d %Y %H:%M:%S"
MSG_FORMAT = "%(now)s - %(category)s - %(data)s\n\n"

def v2_playbook_on_stats(self, stats):
artifact_dir = os.getenv('AWX_ISOLATED_DATA_DIR')
if not artifact_dir:
raise RuntimeError('Only suitable in AWX, did not find private_data_dir')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does Only suitable in AWX mean here? Wouldn't this plugin be only suitable for AAP instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're writing a plugin that is usable generally in an ansible-playbook run. This error should be seen if someone ever does that (enable the plugin, use in ansible-playbook) outside of a AWX/controller job.


collections_print = {}
for collection_obj in list_collections():
collection_print = {
'version': collection_obj.ver,
}
host_query_path = os.path.join(to_text(collection_obj.src), 'meta', 'event_query.yml')
if os.path.exists(host_query_path):
with open(host_query_path, 'r') as f:
collection_print['host_query'] = f.read()
collections_print[collection_obj.fqcn] = collection_print

ansible_data = {'installed_collections': collections_print, 'ansible_version': __version__}

write_path = os.path.join(artifact_dir, 'ansible_data.json')
with open(write_path, "w") as fd:
fd.write(json.dumps(ansible_data, indent=2))

super().v2_playbook_on_stats(stats)
3 changes: 3 additions & 0 deletions awx/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@

AWX_CALLBACK_PROFILE = True

# this modifies FLAGS set by defaults
FLAGS['FEATURE_INDIRECT_NODE_COUNTING_ENABLED'] = [{'condition': 'boolean', 'value': True}] # noqa

# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!=================================
# Disable normal scheduled/triggered task managers (DependencyManager, TaskManager, WorkflowManager).
# Allows user to trigger task managers directly for debugging and profiling purposes.
Expand Down
Loading