Compare commits

..

2 Commits

Author SHA1 Message Date
Peter Braun
81b9c1f294 Merge branch 'devel' into AAP-60052 2026-05-27 15:39:23 +02:00
Peter Braun
c64793d5db feat: remove extra_vars from jobs and unified_jobs list endpoint. Add include query parameter. 2026-05-27 15:14:01 +02:00
62 changed files with 648 additions and 2413 deletions

55
.github/workflows/_repo-owns-branch.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
---
name: Repo Owns Branch
# Reusable workflow that determines whether the current repository
# owns the current branch for push operations.
#
# Ownership rules:
# - ansible/awx owns: devel, feature_*
# - ansible/tower owns: stable-*, release_*
# - workflow_dispatch is always allowed
#
# All other repo/branch combinations are skipped.
on:
workflow_call:
outputs:
should_run:
description: Whether this repo owns the current branch
value: ${{ jobs.check.outputs.should_run }}
jobs:
check:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Check branch ownership
id: check
run: |
REPO="${{ github.repository }}"
BRANCH="${{ github.ref_name }}"
EVENT="${{ github.event_name }}"
if [[ "$EVENT" == "workflow_dispatch" ]]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Manual trigger — allowed"
exit 0
fi
# ansible/awx owns devel and feature_* branches
if [[ "$REPO" == "ansible/awx" ]] && [[ "$BRANCH" == "devel" || "$BRANCH" == feature_* ]]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Repository '$REPO' owns branch '$BRANCH'"
exit 0
fi
# ansible/tower owns stable-* and release_* branches
if [[ "$REPO" == "ansible/tower" ]] && [[ "$BRANCH" == stable-* || "$BRANCH" == release_* ]]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Repository '$REPO' owns branch '$BRANCH'"
exit 0
fi
echo "should_run=false" >> $GITHUB_OUTPUT
echo "Repository '$REPO' does not own branch '$BRANCH' — skipping"

View File

@@ -12,11 +12,12 @@ on:
- feature_*
- stable-*
jobs:
check-ownership:
uses: ./.github/workflows/_repo-owns-branch.yml
push-development-images:
if: |
github.event_name == 'workflow_dispatch' ||
(github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) ||
(github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_')))
needs: check-ownership
if: needs.check-ownership.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:

View File

@@ -20,11 +20,12 @@ on:
- 'stable-2.[1-9][0-9]'
workflow_dispatch: # Allow manual triggering for testing
jobs:
check-ownership:
uses: ./.github/workflows/_repo-owns-branch.yml
sync-openapi-spec:
if: |
github.event_name == 'workflow_dispatch' ||
(github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) ||
(github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_')))
needs: check-ownership
if: needs.check-ownership.outputs.should_run == 'true'
name: Sync OpenAPI spec to central repo
runs-on: ubuntu-latest
permissions:

View File

@@ -13,11 +13,12 @@ on:
- feature_**
- stable-**
jobs:
check-ownership:
uses: ./.github/workflows/_repo-owns-branch.yml
push:
if: |
github.event_name == 'workflow_dispatch' ||
(github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) ||
(github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_')))
needs: check-ownership
if: needs.check-ownership.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:

View File

@@ -27,7 +27,7 @@ spec:
- name: name
value: aap-api-tests
- name: bundle
value: quay.io/aap-ci/tekton-catalog/pipeline/test/aap-api-tests:0.1@sha256:50aadd6725a239ab53247deb7cf601d1163ceb1792792fd239a3f37d21a490d7
value: quay.io/aap-ci/tekton-catalog/pipeline/test/aap-api-tests:0.1@sha256:54d9e941748bae94b2154b3b253a985e628751dfa4508a138d9b05f74a3c1ddf
- name: kind
value: pipeline
- name: secret

View File

@@ -52,6 +52,14 @@ except ImportError: # pragma: no cover
MODE = 'production'
try:
import django # noqa: F401
except ImportError:
pass
else:
from django.db import connection
def prepare_env():
# Update the default settings environment variable based on current mode.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings')
@@ -71,6 +79,14 @@ def manage():
from django.conf import settings
from django.core.management import execute_from_command_line
# enforce the postgres version is a minimum of 12 (we need this for partitioning); if not, then terminate program with exit code of 1
# In the future if we require a feature of a version of postgres > 12 this should be updated to reflect that.
# The return of connection.pg_version is something like 12013
if not os.getenv('SKIP_PG_VERSION_CHECK', False) and not MODE == 'development':
if (connection.pg_version // 10000) < 12:
sys.stderr.write("At a minimum, postgres version 12 is required\n")
sys.exit(1)
if len(sys.argv) >= 2 and sys.argv[1] in ('version', '--version'): # pragma: no cover
sys.stdout.write('%s\n' % __version__)
# If running as a user without permission to read settings, display an

View File

@@ -1,471 +0,0 @@
{
"activity_stream_retrieve": "Retrieve an audit trail entry for tracking all changes within the system",
"ad_hoc_commands_activity_stream_list": "List activity stream of an ad hoc command",
"ad_hoc_commands_create": "Create an ad hoc command",
"ad_hoc_commands_destroy": "Delete an ad hoc command",
"ad_hoc_commands_events_list": "List events of an ad hoc command",
"ad_hoc_commands_list": "List ad hoc commands",
"ad_hoc_commands_notifications_list": "List notifications of an ad hoc command",
"ad_hoc_commands_retrieve": "Retrieve an ad hoc command",
"ad_hoc_commands_stdout_retrieve": "Retrieve a stdout output of an ad hoc command",
"analytics_adoption_rate_options_retrieve": "Retrieve single analytics adoption rate option",
"analytics_adoption_rate_retrieve": "Retrieve single analytics adoption rate",
"analytics_event_explorer_options_retrieve": "Retrieve single analytics event explorer option",
"analytics_event_explorer_retrieve": "Retrieve single analytics event explorer",
"analytics_host_explorer_options_retrieve": "Retrieve single analytics host explorer option",
"analytics_host_explorer_retrieve": "Retrieve single analytics host explorer",
"analytics_job_explorer_options_retrieve": "Retrieve single analytics job explorer option",
"analytics_job_explorer_retrieve": "Retrieve single analytics job explorer",
"analytics_probe_template_for_hosts_options_retrieve": "Retrieve single analytics probe template for hosts option",
"analytics_probe_template_for_hosts_retrieve": "Retrieve single analytics probe template for host",
"analytics_probe_templates_options_retrieve": "Retrieve single analytics probe templates option",
"analytics_probe_templates_retrieve": "Retrieve single analytics probe template",
"analytics_reports_retrieve": "Retrieve single analytics report",
"analytics_roi_templates_options_retrieve": "Retrieve single analytics roi templates option",
"analytics_roi_templates_retrieve": "Retrieve single analytics roi template",
"constructed_inventories_create": "Create a constructed inventory",
"constructed_inventories_destroy": "Delete a constructed inventory",
"constructed_inventories_partial_update": "Update a constructed inventory",
"constructed_inventories_retrieve": "Retrieve a constructed inventory",
"constructed_inventories_update": "Update a constructed inventory",
"credential_input_sources_create": "Create a credential input source",
"credential_input_sources_destroy": "Delete a credential input source",
"credential_input_sources_list": "List credential input sources",
"credential_input_sources_partial_update": "Update a credential input source",
"credential_input_sources_retrieve": "Retrieve a credential input source",
"credential_input_sources_update": "Update a credential input source",
"credential_types_credentials_create": "Create a credential of a credential type",
"credential_types_credentials_list": "List credentials of a credential type",
"credential_types_retrieve": "Retrieve a credential type",
"credential_types_test_retrieve": "Retrieve single test for a credential_type",
"credentials_destroy": "Delete a credential",
"credentials_input_sources_create": "Create new source for a credential",
"credentials_input_sources_list": "List all sources for a credential",
"credentials_object_roles_list": "List roles of a credential",
"credentials_owner_teams_list": "List all teams for a credential",
"credentials_owner_users_list": "List all users for a credential",
"credentials_partial_update": "Update a credential",
"credentials_retrieve": "Retrieve a credential",
"credentials_test_retrieve": "Retrieve a test external credential",
"credentials_update": "Update a credential",
"execution_environments_activity_stream_list": "List activity stream of an execution environment",
"execution_environments_copy_create": "Create new copy for an execution_environment",
"execution_environments_copy_retrieve": "Retrieve single copy for an execution_environment",
"execution_environments_retrieve": "Retrieve an execution environment",
"execution_environments_unified_job_templates_list": "List unified job templates using this execution environment",
"feature_flags_state_retrieve": "Retrieve single feature flags state",
"feature_flags_states_list": "List all feature flags states",
"feature_flags_states_retrieve": "Retrieve single feature flags state",
"groups_activity_stream_list": "List activity stream for a group",
"groups_ad_hoc_commands_create": "Create an ad hoc command for a group",
"groups_ad_hoc_commands_list": "List ad hoc commands for a group",
"groups_all_hosts_list": "List all hosts for a group",
"groups_children_create": "Create new child for a group",
"groups_children_list": "List all children for a group",
"groups_destroy": "Delete a group",
"groups_hosts_create": "Create a host of a group",
"groups_hosts_list": "List hosts of a group",
"groups_inventory_sources_list": "List inventory sources of a group",
"groups_job_events_list": "List job events for a group",
"groups_job_host_summaries_list": "List job host summaries for a group",
"groups_partial_update": "Update a group",
"groups_potential_children_list": "List all children for a group",
"groups_retrieve": "Retrieve a group",
"groups_update": "Update a group",
"groups_variable_data_partial_update": "Update a variable datum for a group",
"groups_variable_data_retrieve": "Retrieve a variable datum for a group",
"groups_variable_data_update": "Update a variable datum for a group",
"host_metric_summary_monthly_list": "List monthly summaries for host metrics",
"host_metrics_list": "List host metrics",
"host_metrics_retrieve": "Retrieve a host metric",
"hosts_activity_stream_list": "List activity stream for a host",
"hosts_ad_hoc_command_events_list": "List events of ad hoc command of a host",
"hosts_ad_hoc_commands_create": "Create an ad hoc command of a host",
"hosts_ad_hoc_commands_list": "List ad hoc commands of a host",
"hosts_all_groups_list": "List all groups for a host",
"hosts_create": "Create a host",
"hosts_groups_create": "Create the list of groups a host is directly a member of",
"hosts_groups_list": "List the list of groups a host is directly a member of",
"hosts_inventory_sources_list": "List inventory sources of a host",
"hosts_job_events_list": "List job events of a host",
"hosts_job_host_summaries_list": "List job summaries of a host",
"hosts_partial_update": "Update a host",
"hosts_retrieve": "Retrieve a host",
"hosts_smart_inventories_list": "List all inventories for a host",
"hosts_update": "Update a host",
"hosts_variable_data_partial_update": "Update a variable datum for a host",
"hosts_variable_data_update": "Update a variable datum for a host",
"instance_groups_destroy": "Delete an instance group",
"instance_groups_instances_create": "Create an instance of an instance group",
"instance_groups_instances_list": "List instance of an instance group",
"instance_groups_jobs_list": "List jobs of an instance group",
"instance_groups_object_roles_list": "List all roles for an instance_group",
"instance_groups_partial_update": "Update an instance group",
"instance_groups_retrieve": "Retrieve an instance group",
"instance_groups_update": "Update an instance group",
"instances_instance_groups_create": "Create an instance group of an instance",
"instances_instance_groups_list": "List instance groups of an instance",
"instances_jobs_list": "List jobs executed on an instance",
"instances_list": "List instances",
"instances_partial_update": "Update an instance",
"instances_peers_list": "List all peers for an instance",
"instances_retrieve": "Retrieve an instance",
"instances_update": "Update an instance",
"inventories_access_list_list": "List users who can access the inventory",
"inventories_ad_hoc_commands_create": "Create an ad hoc command for an inventory",
"inventories_ad_hoc_commands_list": "List ad hoc command for an inventory",
"inventories_copy_create": "Create a copy of an inventory",
"inventories_copy_retrieve": "Retrieve a copy of an inventory",
"inventories_create": "Create an inventory",
"inventories_destroy": "Delete an inventory",
"inventories_groups_create": "Create a group of an inventory",
"inventories_groups_list": "List groups of an inventory",
"inventories_hosts_create": "Create a host of an inventory",
"inventories_hosts_list": "List hosts of an inventory",
"inventories_instance_groups_create": "Create an instance group of an inventory",
"inventories_instance_groups_list": "List instance groups of an inventory",
"inventories_inventory_sources_create": "Create an inventory source",
"inventories_inventory_sources_list": "List inventory sources",
"inventories_job_templates_list": "List job templates using an inventory",
"inventories_labels_list": "List labels of an inventory",
"inventories_object_roles_list": "List roles of an inventory",
"inventories_partial_update": "Update an inventory",
"inventories_retrieve": "Retrieve an inventory",
"inventories_update": "Update an inventory",
"inventories_update_inventory_sources_retrieve": "Retrieve single source for an inventory",
"inventories_variable_data_partial_update": "Partially update existing datum for an inventory",
"inventories_variable_data_retrieve": "Retrieve single datum for an inventory",
"inventories_variable_data_update": "Update existing datum for an inventory",
"inventory_sources_activity_stream_list": "List activity stream of an inventory source",
"inventory_sources_create": "Create an inventory source",
"inventory_sources_credentials_create": "Create a credential of an inventory source",
"inventory_sources_credentials_list": "List credentials of an inventory source",
"inventory_sources_destroy": "Delete an inventory source",
"inventory_sources_groups_destroy": "Delete a group of an inventory source",
"inventory_sources_groups_list": "List groups of an inventory source",
"inventory_sources_hosts_destroy": "Delete a host of an inventory source",
"inventory_sources_hosts_list": "List hosts of an inventory source",
"inventory_sources_inventory_updates_list": "List inventory updates of an inventory source",
"inventory_sources_list": "List inventory sources",
"inventory_sources_notification_templates_error_list": "List notification templates triggered on inventory source update error",
"inventory_sources_notification_templates_started_list": "List notification templates triggered on inventory source update start",
"inventory_sources_notification_templates_success_list": "List notification templates triggered on inventory source update success",
"inventory_sources_partial_update": "Update an inventory source",
"inventory_sources_retrieve": "Retrieve an inventory source",
"inventory_sources_schedules_create": "Create a schedule of an inventory source",
"inventory_sources_schedules_list": "List schedules of an inventory source",
"inventory_sources_update": "Update an inventory source",
"inventory_sources_update_retrieve": "Retrieve an update for an inventory source",
"inventory_updates_cancel_create": "Create a cancel for an inventory update",
"inventory_updates_cancel_retrieve": "Retrieve a cancel for an inventory update",
"inventory_updates_credentials_list": "List credentials of an inventory update",
"inventory_updates_destroy": "Delete an inventory update",
"inventory_updates_events_list": "List events of an inventory update",
"inventory_updates_list": "List inventory updates",
"inventory_updates_notifications_list": "List notifications of an inventory update",
"inventory_updates_retrieve": "Retrieve an inventory update",
"inventory_updates_stdout_retrieve": "Retrieve a stdout output of an inventory update",
"job_events_children_list": "List child events of a job event",
"job_events_retrieve": "Retrieve a job event detail",
"job_host_summaries_retrieve": "Retrieve a job host summary detail",
"job_templates_access_list_list": "List users who can access a job template",
"job_templates_activity_stream_list": "List activity stream of a job template",
"job_templates_copy_create": "Create a copy a job template",
"job_templates_copy_retrieve": "Retrieve a copy a job template",
"job_templates_create": "Create a job template",
"job_templates_credentials_create": "Create a credential of a job template",
"job_templates_credentials_list": "List credentials of a job template",
"job_templates_destroy": "Delete a job template",
"job_templates_instance_groups_create": "Create an instance group of a job template",
"job_templates_instance_groups_list": "List instance groups of a job template",
"job_templates_jobs_list": "List jobs of a job template",
"job_templates_labels_list": "List labels of a job template",
"job_templates_launch_retrieve": "Retrieve single launch for a job_template",
"job_templates_notification_templates_error_create": "Create a notification templates triggered on job error",
"job_templates_notification_templates_error_list": "List notification templates triggered on job error",
"job_templates_notification_templates_started_create": "Create a notification templates triggered on job start",
"job_templates_notification_templates_started_list": "List notification templates triggered on job start",
"job_templates_notification_templates_success_create": "Create a notification templates triggered on job success",
"job_templates_notification_templates_success_list": "List notification templates triggered on job success",
"job_templates_object_roles_list": "List roles of a job template",
"job_templates_partial_update": "Update a job template",
"job_templates_retrieve": "Retrieve a job template",
"job_templates_schedules_create": "Create a schedule of a job template",
"job_templates_schedules_list": "List schedules of a job template",
"job_templates_slice_workflow_jobs_create": "Create new job for a job_template",
"job_templates_slice_workflow_jobs_list": "List all jobs for a job_template",
"job_templates_update": "Update a job template",
"jobs_activity_stream_list": "List activity stream of a job",
"jobs_cancel_retrieve": "Retrieve a cancel for a job",
"jobs_create_schedule_retrieve": "Retrieve single schedule for a job",
"jobs_credentials_list": "List credentials of a job",
"jobs_destroy": "Delete a job",
"jobs_job_events_list": "List job events of a job",
"jobs_job_host_summaries_list": "List job host summaries of a job",
"jobs_labels_list": "List labels of a job",
"jobs_notifications_list": "List notifications of a job",
"jobs_relaunch_retrieve": "Retrieve single relaunch for a job",
"jobs_retrieve": "Retrieve a job",
"labels_create": "Create a label",
"labels_list": "List labels",
"labels_partial_update": "Update a label",
"labels_retrieve": "Retrieve a label",
"labels_update": "Update a label",
"me_list": "List current authenticated user",
"notification_templates_copy_create": "Create a copy a notification template",
"notification_templates_copy_retrieve": "Retrieve a copy a notification template",
"notification_templates_notifications_list": "List notifications of a notification template",
"notification_templates_retrieve": "Retrieve a notification template",
"notifications_list": "List notifications",
"notifications_retrieve": "Retrieve a notification",
"organizations_access_list_list": "List users who can access the organization",
"organizations_activity_stream_list": "List activity stream for an organization",
"organizations_admins_create": "Create new admin for an organization",
"organizations_admins_list": "List all admins for an organization",
"organizations_create": "Create an organization",
"organizations_credentials_create": "Create a credential of an organization",
"organizations_credentials_list": "List credentials of an organization",
"organizations_destroy": "Delete an organization",
"organizations_execution_environments_create": "Create an execution environment of an organization",
"organizations_execution_environments_list": "List execution environments of an organization",
"organizations_galaxy_credentials_create": "Create new credential for an organization",
"organizations_galaxy_credentials_list": "List all credentials for an organization",
"organizations_instance_groups_create": "Create an instance group of an organization",
"organizations_instance_groups_list": "List instance groups of an organization",
"organizations_inventories_list": "List inventories of an organization",
"organizations_job_templates_create": "Create a job template of an organization",
"organizations_job_templates_list": "List job templates of an organization",
"organizations_notification_templates_approvals_create": "Create new approval for an organization",
"organizations_notification_templates_approvals_list": "List all approvals for an organization",
"organizations_notification_templates_create": "Create a notification template of an organization",
"organizations_notification_templates_error_create": "Create new error for an organization",
"organizations_notification_templates_error_list": "List all error for an organization",
"organizations_notification_templates_list": "List notification templates of an organization",
"organizations_notification_templates_started_create": "Create new started for an organization",
"organizations_notification_templates_started_list": "List all started for an organization",
"organizations_notification_templates_success_create": "Create new success for an organization",
"organizations_notification_templates_success_list": "List all success for an organization",
"organizations_object_roles_list": "List roles of an organization",
"organizations_partial_update": "Update an organization",
"organizations_projects_create": "Create a project of an organization",
"organizations_projects_list": "List projects of an organization",
"organizations_retrieve": "Retrieve an organization",
"organizations_retrieve_2": "Retrieve an organization",
"organizations_teams_create": "Create a team of an organization",
"organizations_teams_list": "List teams of an organization",
"organizations_update": "Update an organization",
"organizations_users_create": "Create a user of an organization",
"organizations_users_list": "List users of an organization",
"organizations_workflow_job_templates_create": "Create a workflow job template of an organization",
"organizations_workflow_job_templates_list": "List workflow job templates of an organization",
"project_updates_cancel_create": "Create new cancel for a project_update",
"project_updates_cancel_retrieve": "Retrieve single cancel for a project_update",
"project_updates_destroy": "Delete a project update",
"project_updates_events_list": "List all events for a project_update",
"project_updates_list": "List project updates",
"project_updates_notifications_list": "List notifications of a project update",
"project_updates_retrieve": "Retrieve a project update",
"project_updates_scm_inventory_updates_list": "List all updates for a project_update",
"project_updates_stdout_retrieve": "Retrieve single stdout for a project_update",
"projects_access_list_list": "List users who can access the project",
"projects_activity_stream_list": "List activity stream for a project",
"projects_copy_create": "Create a copy of a project",
"projects_copy_retrieve": "Retrieve a copy of a project",
"projects_create": "Create a project",
"projects_destroy": "Delete a project",
"projects_inventories_retrieve": "Retrieve an inventory from a project",
"projects_notification_templates_error_create": "Create a notification template for project error events",
"projects_notification_templates_error_list": "List notification templates for project error events",
"projects_notification_templates_started_create": "Create a notification template for project started events",
"projects_notification_templates_started_list": "List notification templates for project started events",
"projects_notification_templates_success_create": "Create a notification template for project success events",
"projects_notification_templates_success_list": "List notification templates for project success events",
"projects_object_roles_list": "List roles of a project",
"projects_partial_update": "Update a project",
"projects_playbooks_retrieve": "Retrieve single playbook for a project",
"projects_project_updates_list": "List project updates of a project",
"projects_retrieve": "Retrieve a project",
"projects_schedules_create": "Create a schedule of a project",
"projects_schedules_list": "List schedules of a project",
"projects_scm_inventory_sources_list": "List all sources for a project",
"projects_teams_list": "List teams with access to a project",
"projects_update": "Update a project",
"projects_update_retrieve": "Retrieve single update for a project",
"receptor_addresses_list": "List receptor addresses",
"receptor_addresses_retrieve": "Retrieve a receptor address",
"role_definitions_create": "Create a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_destroy": "Delete a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_list": "List RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_partial_update": "Update a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_retrieve": "Retrieve a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_team_assignments_list": "List all assignments for a role_definition",
"role_definitions_update": "Update a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_user_assignments_list": "List all assignments for a role_definition",
"role_metadata_retrieve": "Retrieve single role metadatum",
"role_team_access_list": "List all role team access",
"role_team_access_list_2": "List all role team access",
"role_team_assignments_create": "Create a RBAC role grants assigning permissions to team for specific resources",
"role_team_assignments_destroy": "Delete a RBAC role grants assigning permissions to team for specific resources",
"role_team_assignments_list": "List RBAC role grants assigning permissions to teams for specific resources",
"role_team_assignments_retrieve": "Retrieve a RBAC role grants assigning permissions to team for specific resources",
"role_user_access_list": "List all role user access",
"role_user_access_list_2": "List all role user access",
"role_user_assignments_create": "Create a RBAC role grants assigning permissions to user for specific resources",
"role_user_assignments_destroy": "Delete a RBAC role grants assigning permissions to user for specific resources",
"role_user_assignments_list": "List RBAC role grants assigning permissions to users for specific resources",
"role_user_assignments_retrieve": "Retrieve a RBAC role grants assigning permissions to user for specific resources",
"roles_list": "List roles",
"roles_retrieve": "Retrieve a role",
"roles_teams_list": "List teams with a role",
"roles_users_list": "List users with a role",
"schedules_create": "Create a schedule",
"schedules_credentials_create": "Create a credential of a schedule",
"schedules_credentials_list": "List credentials of a schedule",
"schedules_destroy": "Delete a schedule",
"schedules_instance_groups_create": "Create an instance group of a schedule",
"schedules_instance_groups_list": "List instance groups of a schedule",
"schedules_jobs_list": "List jobs created by a schedule",
"schedules_labels_list": "List labels of a schedule",
"schedules_list": "List schedules",
"schedules_partial_update": "Update a schedule",
"schedules_retrieve": "Retrieve a schedule",
"schedules_update": "Update a schedule",
"service_index_metadata_retrieve": "Retrieve single service index metadatum",
"service_index_resource_types_list": "List all service index resource types",
"service_index_resource_types_manifest_retrieve": "Retrieve single manifest for a resource-type",
"service_index_resource_types_retrieve": "Retrieve single service index resource type",
"service_index_resources_create": "Create new service index resource",
"service_index_resources_destroy": "Delete existing service index resource",
"service_index_resources_list": "List all service index resources",
"service_index_resources_partial_update": "Partially update existing service index resource",
"service_index_resources_retrieve": "Retrieve single service index resource",
"service_index_resources_update": "Update existing service index resource",
"service_index_retrieve": "Retrieve single service index",
"service_index_role_permissions_list": "List all service index role permissions",
"service_index_role_team_assignments_assign_create": "Create new service index role team assignments assign",
"service_index_role_team_assignments_list": "List all service index role team assignments",
"service_index_role_team_assignments_unassign_create": "Create new service index role team assignments unassign",
"service_index_role_types_list": "List all service index role types",
"service_index_role_user_assignments_assign_create": "Create new service index role user assignments assign",
"service_index_role_user_assignments_list": "List all service index role user assignments",
"service_index_role_user_assignments_unassign_create": "Create new service index role user assignments unassign",
"settings_destroy": "Delete existing setting",
"settings_logging_test_create": "Create new settings logging test",
"settings_retrieve": "Retrieve single setting",
"settings_update": "Update existing setting",
"system_job_templates_jobs_list": "List system jobs of a system job template",
"system_job_templates_notification_templates_error_create": "Create a notification templates triggered on system job error",
"system_job_templates_notification_templates_error_list": "List notification templates triggered on system job error",
"system_job_templates_notification_templates_started_create": "Create a notification templates triggered on system job start",
"system_job_templates_notification_templates_started_list": "List notification templates triggered on system job start",
"system_job_templates_notification_templates_success_create": "Create a notification templates triggered on system job success",
"system_job_templates_notification_templates_success_list": "List notification templates triggered on system job success",
"system_job_templates_retrieve": "Retrieve a system job template",
"system_job_templates_schedules_create": "Create a schedule of a system job template",
"system_job_templates_schedules_list": "List schedules of a system job template",
"system_jobs_cancel_create": "Create a cancel for a system job",
"system_jobs_cancel_retrieve": "Retrieve a cancel for a system job",
"system_jobs_destroy": "Delete a system job",
"system_jobs_events_list": "List events of a system job",
"system_jobs_notifications_list": "List notifications of a system job",
"system_jobs_retrieve": "Retrieve a system job",
"teams_access_list_list": "List users who can access the team",
"teams_activity_stream_list": "List activity stream for a team",
"teams_create": "Create a team",
"teams_credentials_create": "Create a credentials owned by a team",
"teams_credentials_list": "List credentials owned by a team",
"teams_destroy": "Delete a team",
"teams_list": "List teams",
"teams_object_roles_list": "List object roles of a team",
"teams_partial_update": "Update a team",
"teams_projects_list": "List projects accessible to a team",
"teams_retrieve": "Retrieve a team",
"teams_roles_list": "List roles of a team",
"teams_update": "Update a team",
"teams_users_create": "Create a user of a team",
"teams_users_list": "List users of a team",
"unified_job_templates_list": "List unified job templates",
"unified_jobs_list": "List unified jobs",
"users_access_list_list": "List users who can access the user",
"users_activity_stream_list": "List activity stream for a user",
"users_admin_of_organizations_retrieve": "Retrieve single organization for an user",
"users_create": "Create a user",
"users_credentials_create": "Create a credentials owned by a user",
"users_credentials_list": "List credentials owned by a user",
"users_destroy": "Delete a user",
"users_list": "List users",
"users_organizations_retrieve": "Retrieve an organization of a user",
"users_partial_update": "Update a user",
"users_projects_list": "List projects accessible to a user",
"users_retrieve": "Retrieve a user",
"users_roles_list": "List roles of a user",
"users_teams_list": "List teams of a user",
"users_update": "Update a user",
"workflow_approval_templates_approvals_list": "List all approvals for a workflow_approval_template",
"workflow_approval_templates_destroy": "Delete a workflow approval template detail",
"workflow_approval_templates_partial_update": "Update a workflow approval template detail",
"workflow_approval_templates_retrieve": "Retrieve a workflow approval template detail",
"workflow_approval_templates_update": "Update a workflow approval template detail",
"workflow_approvals_approve_retrieve": "Retrieve single approve for a workflow_approval",
"workflow_approvals_deny_retrieve": "Retrieve single deny for a workflow_approval",
"workflow_approvals_destroy": "Delete a workflow approval",
"workflow_approvals_retrieve": "Retrieve a workflow approval",
"workflow_job_nodes_always_nodes_list": "List always nodes of a workflow job node",
"workflow_job_nodes_credentials_list": "List credentials of a workflow job node",
"workflow_job_nodes_failure_nodes_list": "List failure nodes of a workflow job node",
"workflow_job_nodes_instance_groups_create": "Create an instance group of a workflow job node",
"workflow_job_nodes_instance_groups_list": "List instance groups of a workflow job node",
"workflow_job_nodes_labels_list": "List labels of a workflow job node",
"workflow_job_nodes_list": "List workflow job nodes",
"workflow_job_nodes_retrieve": "Retrieve a workflow job node",
"workflow_job_nodes_success_nodes_list": "List success nodes of a workflow job node",
"workflow_job_template_nodes_always_nodes_create": "Create new node for a workflow_job_template_node",
"workflow_job_template_nodes_always_nodes_list": "List all nodes for a workflow_job_template_node",
"workflow_job_template_nodes_create": "Create a workflow job template node",
"workflow_job_template_nodes_create_approval_template_retrieve": "Retrieve single template for a workflow_job_template_node",
"workflow_job_template_nodes_credentials_create": "Create a credential of a workflow job template node",
"workflow_job_template_nodes_credentials_list": "List credentials of a workflow job template node",
"workflow_job_template_nodes_destroy": "Delete a workflow job template node",
"workflow_job_template_nodes_failure_nodes_create": "Create new node for a workflow_job_template_node",
"workflow_job_template_nodes_failure_nodes_list": "List all nodes for a workflow_job_template_node",
"workflow_job_template_nodes_instance_groups_create": "Create an instance group of a workflow job template node",
"workflow_job_template_nodes_instance_groups_list": "List instance groups of a workflow job template node",
"workflow_job_template_nodes_labels_list": "List labels of a workflow job template node",
"workflow_job_template_nodes_list": "List workflow job template nodes",
"workflow_job_template_nodes_partial_update": "Update a workflow job template node",
"workflow_job_template_nodes_retrieve": "Retrieve a workflow job template node",
"workflow_job_template_nodes_success_nodes_create": "Create new node for a workflow_job_template_node",
"workflow_job_template_nodes_success_nodes_list": "List all nodes for a workflow_job_template_node",
"workflow_job_template_nodes_update": "Update a workflow job template node",
"workflow_job_templates_access_list_list": "List users who can access a workflow job template",
"workflow_job_templates_activity_stream_list": "List activity stream of a workflow job template",
"workflow_job_templates_copy_create": "Create a copy a workflow job template",
"workflow_job_templates_create": "Create a workflow job template",
"workflow_job_templates_destroy": "Delete a workflow job template",
"workflow_job_templates_labels_list": "List labels of a workflow job template",
"workflow_job_templates_launch_retrieve": "Retrieve a launch a workflow job from a workflow job template",
"workflow_job_templates_notification_templates_approvals_create": "Create a notification templates triggered on workflow approval",
"workflow_job_templates_notification_templates_approvals_list": "List notification templates triggered on workflow approval",
"workflow_job_templates_notification_templates_error_create": "Create a notification templates triggered on workflow job error",
"workflow_job_templates_notification_templates_error_list": "List notification templates triggered on workflow job error",
"workflow_job_templates_notification_templates_started_create": "Create a notification templates triggered on workflow job start",
"workflow_job_templates_notification_templates_started_list": "List notification templates triggered on workflow job start",
"workflow_job_templates_notification_templates_success_create": "Create a notification templates triggered on workflow job success",
"workflow_job_templates_notification_templates_success_list": "List notification templates triggered on workflow job success",
"workflow_job_templates_object_roles_list": "List roles of a workflow job template",
"workflow_job_templates_partial_update": "Update a workflow job template",
"workflow_job_templates_retrieve": "Retrieve a workflow job template",
"workflow_job_templates_schedules_create": "Create a schedule of a workflow job template",
"workflow_job_templates_schedules_list": "List schedules of a workflow job template",
"workflow_job_templates_update": "Update a workflow job template",
"workflow_job_templates_workflow_jobs_list": "List workflow jobs of a workflow job template",
"workflow_job_templates_workflow_nodes_create": "Create new node for a workflow_job_template",
"workflow_job_templates_workflow_nodes_list": "List all nodes for a workflow_job_template",
"workflow_jobs_activity_stream_list": "List activity stream of a workflow job",
"workflow_jobs_cancel_retrieve": "Retrieve a cancel for a workflow job",
"workflow_jobs_destroy": "Delete a workflow job",
"workflow_jobs_labels_list": "List labels of a workflow job",
"workflow_jobs_notifications_list": "List notifications of a workflow job",
"workflow_jobs_retrieve": "Retrieve a workflow job",
"workflow_jobs_workflow_nodes_list": "List workflow nodes of a workflow job"
}

View File

@@ -1,5 +1,3 @@
import json
import os
import warnings
from rest_framework.permissions import IsAuthenticated
@@ -55,37 +53,6 @@ def filter_credential_type_schema(
return result
def inject_ai_descriptions(
result,
generator, # NOSONAR
request, # NOSONAR
public, # NOSONAR
):
"""
Inject x-ai-description into operations from the overlay file.
Many endpoints have human-readable AI descriptions that were added
downstream but not backported as @extend_schema_if_available decorators.
This hook merges them from a JSON file keyed by operationId.
"""
overlay_path = os.path.join(os.path.dirname(__file__), 'openapi_ai_descriptions.json')
try:
with open(overlay_path) as f:
descriptions = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return result
for path_item in result.get('paths', {}).values():
for operation in path_item.values():
if not isinstance(operation, dict):
continue
op_id = operation.get('operationId')
if op_id and op_id in descriptions and 'x-ai-description' not in operation:
operation['x-ai-description'] = descriptions[op_id]
return result
class CustomAutoSchema(AutoSchema):
"""Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints."""

View File

@@ -120,7 +120,7 @@ from awx.main.utils.named_url_graph import reset_counters
from awx.main.utils.inventory_vars import update_group_variables
from awx.main.scheduler.task_manager_models import TaskManagerModels
from awx.main.redact import UriCleaner, REPLACE_STR
from awx.main.tasks.system import update_inventory_computed_fields
from awx.main.signals import update_inventory_computed_fields
from awx.main.validators import vars_validate_or_raise
@@ -961,14 +961,32 @@ class UnifiedJobSerializer(BaseSerializer):
class UnifiedJobListSerializer(UnifiedJobSerializer):
# these fields can be included optionally in the response
OPTIONAL_INCLUDE_FIELDS = frozenset({'artifacts', 'extra_vars'})
# these fields are stripped from the response
_STRIPPED_FIELDS = frozenset({'job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts', 'extra_vars'})
class Meta:
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts')
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts', '-extra_vars')
# processes the include query param if present
def _requested_includes(self):
request = self.context.get('request')
if request is None:
return frozenset()
raw = request.query_params.get('include', '')
requested = {name.strip() for name in raw.split(',') if name.strip()}
# only allow the fields listed in OPTIONAL_INCLUDE_FIELDS
return frozenset(requested) & self.OPTIONAL_INCLUDE_FIELDS
def get_field_names(self, declared_fields, info):
field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info)
# Meta multiple inheritance and -field_name options don't seem to be
# taking effect above, so remove the undesired fields here.
return tuple(x for x in field_names if x not in ('job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts'))
strip = self._STRIPPED_FIELDS - self._requested_includes()
return tuple(x for x in field_names if x not in strip)
def get_types(self):
if type(self) is UnifiedJobListSerializer:
@@ -5450,11 +5468,7 @@ class SchedulePreviewSerializer(BaseSerializer):
for a_rule in match_multiple_rrule:
if 'interval' not in a_rule.lower():
errors.append("{0}: {1}".format(_('INTERVAL required in rrule'), a_rule))
else:
match_interval = re.match(r".*?INTERVAL=([0-9]+)", a_rule)
if match_interval and int(match_interval.group(1)) < 1:
errors.append("{0}: {1}".format(_("INTERVAL must be a positive integer"), a_rule))
if 'secondly' in a_rule.lower():
elif 'secondly' in a_rule.lower():
errors.append("{0}: {1}".format(_('SECONDLY is not supported'), a_rule))
if re.match(by_day_with_numeric_prefix, a_rule):
errors.append("{0}: {1}".format(_("BYDAY with numeric prefix not supported"), a_rule))

View File

@@ -127,6 +127,7 @@ from awx.api.views.mixin import (
RelatedJobsPreventDeleteMixin,
UnifiedJobDeletionMixin,
NoTruncateMixin,
UnifiedJobIncludeMixin,
)
from awx.api.pagination import UnifiedJobEventPagination
from awx.main.utils import set_environ
@@ -3850,7 +3851,7 @@ class SystemJobTemplateNotificationTemplatesSuccessList(SystemJobTemplateNotific
resource_purpose = 'notification templates triggered on system job success'
class JobList(ListAPIView):
class JobList(UnifiedJobIncludeMixin, ListAPIView):
model = models.Job
serializer_class = serializers.JobListSerializer
resource_purpose = 'jobs'
@@ -4567,7 +4568,7 @@ class UnifiedJobTemplateList(ListAPIView):
resource_purpose = 'unified job templates'
class UnifiedJobList(ListAPIView):
class UnifiedJobList(UnifiedJobIncludeMixin, ListAPIView):
model = models.UnifiedJob
serializer_class = serializers.UnifiedJobListSerializer
search_fields = ('description', 'name', 'job__playbook')

View File

@@ -9,7 +9,7 @@ from django.utils import translation
from awx.api.generics import APIView, Response
from awx.api.permissions import AnalyticsPermission
from awx.api.versioning import reverse
from awx.main.utils import get_awx_version
from awx.main.utils import get_awx_version, set_environ
from awx.main.utils.analytics_proxy import OIDCClient
from rest_framework import status
@@ -210,31 +210,32 @@ class AnalyticsGenericView(APIView):
return self._error_response(ERROR_UNSUPPORTED_METHOD, method, remote=False, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
url = self._get_analytics_url(request.path)
using_subscriptions_credentials = False
try:
rh_user = getattr(settings, 'REDHAT_USERNAME', None)
rh_password = getattr(settings, 'REDHAT_PASSWORD', None)
if not (rh_user and rh_password):
rh_user = self._get_setting('SUBSCRIPTIONS_CLIENT_ID', None, ERROR_MISSING_USER)
rh_password = self._get_setting('SUBSCRIPTIONS_CLIENT_SECRET', None, ERROR_MISSING_PASSWORD)
using_subscriptions_credentials = True
with set_environ(**settings.AWX_TASK_ENV):
try:
rh_user = getattr(settings, 'REDHAT_USERNAME', None)
rh_password = getattr(settings, 'REDHAT_PASSWORD', None)
if not (rh_user and rh_password):
rh_user = self._get_setting('SUBSCRIPTIONS_CLIENT_ID', None, ERROR_MISSING_USER)
rh_password = self._get_setting('SUBSCRIPTIONS_CLIENT_SECRET', None, ERROR_MISSING_PASSWORD)
using_subscriptions_credentials = True
client = OIDCClient(rh_user, rh_password)
response = client.make_request(
method,
url,
headers=headers,
verify=settings.INSIGHTS_CERT_PATH,
params=getattr(request, 'query_params', {}),
json=getattr(request, 'data', {}),
timeout=(31, 31),
)
except requests.RequestException:
# subscriptions credentials are not valid for basic auth, so just return 401
if using_subscriptions_credentials:
response = Response(status=status.HTTP_401_UNAUTHORIZED)
else:
logger.error("Automation Analytics API request failed, trying base auth method")
response = self._base_auth_request(request, method, url, rh_user, rh_password, headers)
client = OIDCClient(rh_user, rh_password)
response = client.make_request(
method,
url,
headers=headers,
verify=settings.INSIGHTS_CERT_PATH,
params=getattr(request, 'query_params', {}),
json=getattr(request, 'data', {}),
timeout=(31, 31),
)
except requests.RequestException:
# subscriptions credentials are not valid for basic auth, so just return 401
if using_subscriptions_credentials:
response = Response(status=status.HTTP_401_UNAUTHORIZED)
else:
logger.error("Automation Analytics API request failed, trying base auth method")
response = self._base_auth_request(request, method, url, rh_user, rh_password, headers)
#
# Missing or wrong user/pass
#

View File

@@ -212,3 +212,9 @@ class NoTruncateMixin(object):
if self.request.query_params.get('no_truncate'):
context.update(no_truncate=True)
return context
class UnifiedJobIncludeMixin(object):
# Reserve the name 'include' so we can use it as a query param. Otherwise, the rest-filters backend
# would treat it as a model field lookup.
rest_filters_reserved_names = ('include',)

View File

@@ -1,25 +1,22 @@
import os
from dispatcherd.config import setup as dispatcher_setup
from django.apps import AppConfig
from django.db import connection
from django.utils.translation import gettext_lazy as _
from django.core.management.base import CommandError
from django.db.models.signals import pre_migrate
from awx.main.utils.common import bypass_in_test, load_all_entry_points_for
from awx.main.utils.migration import is_database_synchronized
from awx.main.utils.named_url_graph import _customize_graph, generate_graph
from awx.main.utils.db import db_requirement_violations
from awx.conf import register, fields
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
class MainConfig(AppConfig):
name = 'awx.main'
verbose_name = _('Main')
def check_db_requirement(self, *args, **kwargs):
violations = db_requirement_violations()
if violations:
raise CommandError(violations)
def load_named_url_feature(self):
models = [m for m in self.get_models() if hasattr(m, 'get_absolute_url')]
generate_graph(models)
@@ -46,6 +43,42 @@ class MainConfig(AppConfig):
category_slug='named-url',
)
def _load_credential_types_feature(self):
"""
Create CredentialType records for any discovered credentials.
Note that Django docs advise _against_ interacting with the database using
the ORM models in the ready() path. Specifically, during testing.
However, we explicitly use the @bypass_in_test decorator to avoid calling this
method during testing.
Django also advises against running pattern because it runs everywhere i.e.
every management command. We use an advisory lock to ensure correctness and
we will deal performance if it becomes an issue.
"""
from awx.main.models.credential import CredentialType
if is_database_synchronized():
CredentialType.setup_tower_managed_defaults(app_config=self)
@bypass_in_test
def load_credential_types_feature(self):
from awx.main.models.credential import load_credentials
load_credentials()
return self._load_credential_types_feature()
def load_inventory_plugins(self):
from awx.main.models.inventory import InventorySourceOptions
is_awx = detect_server_product_name() == 'AWX'
extra_entry_point_groups = () if is_awx else ('inventory.supported',)
entry_points = load_all_entry_points_for(['inventory', *extra_entry_point_groups])
for entry_point_name, entry_point in entry_points.items():
cls = entry_point.load()
InventorySourceOptions.injectors[entry_point_name] = cls
def configure_dispatcherd(self):
"""This implements the default configuration for dispatcherd
@@ -67,5 +100,13 @@ class MainConfig(AppConfig):
super().ready()
self.configure_dispatcherd()
"""
Credential loading triggers database operations. There are cases we want to call
awx-manage collectstatic without a database. All management commands invoke the ready() code
path. Using settings.AWX_SKIP_CREDENTIAL_TYPES_DISCOVER _could_ invoke a database operation.
"""
if not os.environ.get('AWX_SKIP_CREDENTIAL_TYPES_DISCOVER', None):
self.load_credential_types_feature()
self.load_named_url_feature()
pre_migrate.connect(self.check_db_requirement, sender=self)
self.load_inventory_plugins()

87
awx/main/cache.py Normal file
View File

@@ -0,0 +1,87 @@
import functools
from django.conf import settings
from django.core.cache.backends.base import DEFAULT_TIMEOUT
from django.core.cache.backends.redis import RedisCache
from redis.exceptions import ConnectionError, ResponseError, TimeoutError
import socket
# This list comes from what django-redis ignores and the behavior we are trying
# to retain while dropping the dependency on django-redis.
IGNORED_EXCEPTIONS = (TimeoutError, ResponseError, ConnectionError, socket.timeout)
CONNECTION_INTERRUPTED_SENTINEL = object()
def optionally_ignore_exceptions(func=None, return_value=None):
if func is None:
return functools.partial(optionally_ignore_exceptions, return_value=return_value)
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except IGNORED_EXCEPTIONS as e:
if settings.DJANGO_REDIS_IGNORE_EXCEPTIONS:
return return_value
raise e.__cause__ or e
return wrapper
class AWXRedisCache(RedisCache):
"""
We just want to wrap the upstream RedisCache class so that we can ignore
the exceptions that it raises when the cache is unavailable.
"""
@optionally_ignore_exceptions
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
return super().add(key, value, timeout, version)
@optionally_ignore_exceptions(return_value=CONNECTION_INTERRUPTED_SENTINEL)
def _get(self, key, default=None, version=None):
return super().get(key, default, version)
def get(self, key, default=None, version=None):
value = self._get(key, default, version)
if value is CONNECTION_INTERRUPTED_SENTINEL:
return default
return value
@optionally_ignore_exceptions
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
return super().set(key, value, timeout, version)
@optionally_ignore_exceptions
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
return super().touch(key, timeout, version)
@optionally_ignore_exceptions
def delete(self, key, version=None):
return super().delete(key, version)
@optionally_ignore_exceptions
def get_many(self, keys, version=None):
return super().get_many(keys, version)
@optionally_ignore_exceptions
def has_key(self, key, version=None):
return super().has_key(key, version)
@optionally_ignore_exceptions
def incr(self, key, delta=1, version=None):
return super().incr(key, delta, version)
@optionally_ignore_exceptions
def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None):
return super().set_many(data, timeout, version)
@optionally_ignore_exceptions
def delete_many(self, keys, version=None):
return super().delete_many(keys, version)
@optionally_ignore_exceptions
def clear(self):
return super().clear()

View File

@@ -25,7 +25,7 @@ def get_dispatcherd_config(for_service: bool = False, mock_publish: bool = False
"version": 2,
"service": {
"pool_kwargs": {
"min_workers": settings.DISPATCHER_MIN_WORKERS,
"min_workers": settings.JOB_EVENT_WORKERS,
"max_workers": max_workers,
# This must be less than max_workers to make sense, which is usually 4
# With reserve of 1, after a burst of tasks, load needs to down to 4-1=3

View File

@@ -1,11 +1,9 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from django.db import connection
from awx.main.utils.db import db_requirement_violations
class Command(BaseCommand):
"""Checks connection to the database, and prints out connection info if not connected"""
@@ -15,8 +13,4 @@ class Command(BaseCommand):
cursor.execute("SELECT version()")
version = str(cursor.fetchone()[0])
violations = db_requirement_violations()
if violations:
raise CommandError(violations)
return "Database Version: {}".format(version)

View File

@@ -52,11 +52,7 @@ class Command(BaseCommand):
ssh_type = CredentialType.objects.filter(namespace='ssh').first()
c, _ = Credential.objects.get_or_create(
credential_type=ssh_type,
name='Demo Credential',
inputs={'username': getattr(superuser, 'username', 'null')},
created_by=superuser,
organization=o,
credential_type=ssh_type, name='Demo Credential', inputs={'username': getattr(superuser, 'username', 'null')}, created_by=superuser
)
if superuser:

View File

@@ -211,7 +211,7 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
return AdHocCommand.objects.create(**data)
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
def add_to_update_fields(name):
if name not in update_fields:

View File

@@ -177,7 +177,7 @@ class CreatedModifiedModel(BaseModel):
)
def save(self, *args, **kwargs):
update_fields = list(kwargs.get('update_fields') or [])
update_fields = list(kwargs.get('update_fields', []))
# Manually perform auto_now_add and auto_now logic.
if not self.pk and not self.created:
self.created = now()
@@ -207,7 +207,7 @@ class PasswordFieldsModel(BaseModel):
new_instance = not bool(self.pk)
# If update_fields has been specified, add our field names to it,
# if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
# When first saving to the database, don't store any password field
# values, but instead save them until after the instance is created.
# Otherwise, store encrypted values to the database.
@@ -322,7 +322,7 @@ class PrimordialModel(HasEditsMixin, CreatedModifiedModel):
self._prior_values_store = {}
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
user = get_current_user()
if user and not user.id:
user = None

View File

@@ -47,7 +47,6 @@ from awx.main.models.rbac import (
)
from awx.main.models import Team, Organization
from awx.main.utils import encrypt_field
from awx.main.utils.lazy_registry import LazyLoadDict
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'build_safe_env']
@@ -570,7 +569,7 @@ class CredentialTypeHelper:
class ManagedCredentialType(SimpleNamespace):
registry = None # initialized as LazyLoadDict after load_credentials is defined
registry = {}
class CredentialInputSource(PrimordialModel):
@@ -662,8 +661,6 @@ def _is_oidc_namespace_disabled(ns):
def load_credentials():
ManagedCredentialType.registry.clear()
awx_entry_points = {ep.name: ep for ep in entry_points(group='awx_plugins.managed_credentials')}
supported_entry_points = {ep.name: ep for ep in entry_points(group='awx_plugins.managed_credentials.supported')}
plugin_entry_points = awx_entry_points if detect_server_product_name() == 'AWX' else {**awx_entry_points, **supported_entry_points}
@@ -695,8 +692,3 @@ def load_credentials():
plugin = ep.load()
CredentialType.load_plugin(ns, plugin)
# load_credentials writes directly into this dict via registry[ns] = ...,
# LazyLoadDict just ensures it runs once before the first read access
ManagedCredentialType.registry = LazyLoadDict(load_credentials)

View File

@@ -27,10 +27,7 @@ from ansible_base.lib.utils.models import prevent_search
# AWX
from awx.api.versioning import reverse
from awx.main.utils.common import load_all_entry_points_for
from awx.main.utils.lazy_registry import LazyLoadDict
from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names, compute_cloud_inventory_sources
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
from awx.main.consumers import emit_channel_notification
from awx.main.fields import (
ImplicitRoleField,
@@ -929,22 +926,12 @@ class HostMetricSummaryMonthly(models.Model):
indirectly_managed_hosts = models.IntegerField(default=0, help_text=("Manually entered number indirectly managed hosts for a certain month"))
def _load_inventory_plugins():
is_awx = detect_server_product_name() == 'AWX'
extra_entry_point_groups = () if is_awx else ('inventory.supported',)
all_entry_points = load_all_entry_points_for(['inventory', *extra_entry_point_groups])
for entry_point_name, entry_point in all_entry_points.items():
cls = entry_point.load()
InventorySourceOptions.injectors[entry_point_name] = cls
class InventorySourceOptions(BaseModel):
"""
Common fields for InventorySource and InventoryUpdate.
"""
injectors = LazyLoadDict(_load_inventory_plugins)
injectors = dict()
# From the options of the Django management base command
INVENTORY_UPDATE_VERBOSITY_CHOICES = [
@@ -1162,7 +1149,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
# If update_fields has been specified, add our field names to it,
# if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
is_new_instance = not bool(self.pk)
# Set name automatically. Include PK (or placeholder) to make sure the names are always unique.

View File

@@ -347,7 +347,7 @@ class JobTemplate(
return actual_slice_count
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
# if project is deleted for some reason, then keep the old organization
# to retain ownership for organization admins
if self.project and self.project.organization_id != self.organization_id:
@@ -1165,7 +1165,7 @@ class JobHostSummary(CreatedModifiedModel):
# if it hasn't been specified, then we're just doing a normal save.
if self.host is not None:
self.host_name = self.host.name
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
self.failed = bool(self.dark or self.failures)
update_fields.append('failed')
super(JobHostSummary, self).save(*args, **kwargs)

View File

@@ -99,7 +99,7 @@ class NotificationTemplate(CommonModelNameNotUnique):
def save(self, *args, **kwargs):
new_instance = not bool(self.pk)
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
# preserve existing notification messages if not overwritten by new messages
if not new_instance:

View File

@@ -367,7 +367,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
pre_save_vals = getattr(self, '_prior_values_store', {})
# If update_fields has been specified, add our field names to it,
# if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
self._skip_update = bool(kwargs.pop('skip_update', False))
# Create auto-generated local path if project uses SCM.
if self.pk and self.scm_type and not self.local_path.startswith('_'):

View File

@@ -613,7 +613,7 @@ def get_role_from_object_role(object_role):
model_name, role_name = rd.name.split()
role_name = role_name.lower()
role_name += '_role'
return getattr(object_role.content_object, role_name, None)
return getattr(object_role.content_object, role_name)
def give_or_remove_permission(role, actor, giving=True, rd=None):
@@ -649,8 +649,6 @@ def give_creator_permissions(user, obj):
if assignment:
with disable_rbac_sync():
old_role = get_role_from_object_role(assignment.object_role)
if old_role is None:
return
old_role.members.add(user)

View File

@@ -305,7 +305,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
def save(self, *args, **kwargs):
# If update_fields has been specified, add our field names to it,
# if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
# Update status and last_updated fields.
if not getattr(_inventory_updates, 'is_updating', False):
updated_fields = self._set_status_and_last_job_run(save=False)
@@ -877,7 +877,7 @@ class UnifiedJob(
"""
# If update_fields has been specified, add our field names to it,
# if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields') or []
update_fields = kwargs.get('update_fields', [])
# Get status before save...
status_before = self.status or 'new'

View File

@@ -900,7 +900,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
return 'workflow_approval_template'
def save(self, *args, **kwargs):
update_fields = list(kwargs.get('update_fields') or [])
update_fields = list(kwargs.get('update_fields', []))
if self.timeout != 0 and ((not self.pk) or (not update_fields) or ('timeout' in update_fields)):
if not self.created: # on creation, created will be set by parent class, so we fudge it here
created = now()

View File

@@ -688,17 +688,6 @@ class TaskManager(TaskBase):
logger.error(f'{j.execution_node} is not a registered instance; reaping {j.log_format}')
reap_job(j, 'failed')
# Reset waiting jobs whose controller_node was deprovisioned (e.g. K8s pod replaced).
# These jobs will never be picked up because no live node is listening for them.
registered_control_nodes = Instance.objects.filter(node_type__in=('control', 'hybrid')).values_list('hostname', flat=True)
orphaned_waiting = UnifiedJob.objects.filter(status='waiting').exclude(controller_node__in=registered_control_nodes)
for j in orphaned_waiting:
logger.warning(f'{j.controller_node} is not a registered instance; resetting {j.log_format} to pending')
j.status = 'pending'
j.controller_node = ''
j.execution_node = ''
j.save(update_fields=['status', 'controller_node', 'execution_node'])
def process_tasks(self):
# maintain a list of jobs that went to an early failure state,
# meaning the dispatcher never got these jobs,

View File

@@ -19,7 +19,6 @@ from dispatcherd.publish import task
# Runner
import ansible_runner.cleanup
import psycopg
from ansible_base.lib.cache.tasks import clear_cache as dab_clear_cache
from ansible_base.lib.utils.db import advisory_lock
# django-ansible-base
@@ -69,12 +68,10 @@ from awx.main.models import (
UnifiedJob,
convert_jsonfields,
)
from awx.main.models.credential import CredentialType
from awx.main.tasks.helpers import is_run_threshold_reached
from awx.main.tasks.host_indirect import save_indirect_host_entries
from awx.main.tasks.receptor import administrative_workunit_reaper, get_receptor_ctl, worker_cleanup, worker_info, write_receptor_config
from awx.main.utils.common import ignore_inventory_computed_fields, ignore_inventory_group_removal
from awx.main.utils.migration import is_database_synchronized
from awx.main.utils.reload import stop_local_services
logger = logging.getLogger('awx.main.tasks.system')
@@ -86,16 +83,6 @@ Try upgrading OpenSSH or providing your private key in an different format. \
'''
def _sync_credential_types_to_db():
"""Ensure CredentialType DB rows match the installed plugins.
The in-memory registry is populated lazily on first access via LazyLoadDict.
This function only handles the DB sync step.
"""
if is_database_synchronized():
CredentialType.setup_tower_managed_defaults()
def _run_dispatch_startup_common():
"""
Execute the common startup initialization steps.
@@ -111,11 +98,6 @@ def _run_dispatch_startup_common():
except Exception:
logger.exception("Failed to write receptor config, skipping.")
try:
_sync_credential_types_to_db()
except Exception:
logger.exception("Failed to sync credential types to DB, skipping.")
try:
convert_jsonfields()
except Exception:
@@ -258,17 +240,12 @@ def apply_cluster_membership_policies():
# Process policy instance list first, these will represent manually managed memberships
instance_hostnames_map = {inst.hostname: inst for inst in all_instances}
for ig in all_groups:
# we don't want to allow execution nodes in the control plane
exclude_type = 'execution' if ig.name == settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME else 'control'
group_actual = Group(obj=ig, instances=[], prior_instances=[instance.pk for instance in ig.instances.all()]) # obtained in prefetch
for hostname in ig.policy_instance_list:
if hostname not in instance_hostnames_map:
logger.info("Unknown instance {} in {} policy list".format(hostname, ig.name))
continue
inst = instance_hostnames_map[hostname]
if inst.node_type == exclude_type:
logger.info("Instance {} is excluded in {} policy list".format(hostname, ig.name))
continue
group_actual.instances.append(inst.id)
# NOTE: arguable behavior: policy-list-group is not added to
# instance's group count for consideration in minimum-policy rules
@@ -349,22 +326,24 @@ def apply_cluster_membership_policies():
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
def _resolve_setting_dependents(key):
return settings_registry.get_dependent_settings(key)
@task(queue='tower_settings_change', timeout=600)
def clear_setting_cache(setting_keys):
# log that cache is being cleared
logger.info(f"clear_setting_cache of keys {setting_keys}")
orig_len = len(setting_keys)
for i in range(orig_len):
for dependent_key in settings_registry.get_dependent_settings(setting_keys[i]):
setting_keys.append(dependent_key)
cache_keys = set(setting_keys)
logger.debug('cache delete_many(%r)', cache_keys)
cache.delete_many(cache_keys)
def _post_setting_invalidation(invalidated_keys):
if 'LOG_AGGREGATOR_LEVEL' in invalidated_keys:
if 'LOG_AGGREGATOR_LEVEL' in setting_keys:
ctl = get_control_from_settings()
ctl.queuename = get_task_queuename()
ctl.control('set_log_level', data={'level': settings.LOG_AGGREGATOR_LEVEL})
@task(queue='tower_settings_change', timeout=600)
def clear_setting_cache(setting_keys):
dab_clear_cache(setting_keys, _resolve_setting_dependents, _post_setting_invalidation)
@task(queue='tower_broadcast_all', timeout=600)
def delete_project_files(project_path):
# TODO: possibly implement some retry logic

View File

@@ -1,3 +1,4 @@
import os
import pytest
import requests
from unittest import mock
@@ -257,3 +258,92 @@ class TestAnalyticsGenericView:
else:
# assert mock_base_auth_request not called
mock_base_auth_request.assert_not_called()
@pytest.mark.django_db
def test__send_to_analytics_respects_proxy_env_oidc(self):
settings_map = {
'INSIGHTS_TRACKING_STATE': True,
'AUTOMATION_ANALYTICS_URL': 'https://example.com',
'REDHAT_USERNAME': 'redhat_user',
'REDHAT_PASSWORD': 'redhat_pass',
'SUBSCRIPTIONS_CLIENT_ID': '',
'SUBSCRIPTIONS_CLIENT_SECRET': '',
'AWX_TASK_ENV': {'HTTPS_PROXY': '192.168.50.100:1234', 'HTTP_PROXY': '192.168.50.100:5678'},
}
with override_settings(**settings_map):
request = RequestFactory().post('/some/path')
view = AnalyticsGenericView()
with mock.patch('awx.api.views.analytics.OIDCClient') as mock_oidc_client:
mock_client_instance = mock.Mock()
mock_oidc_client.return_value = mock_client_instance
def _check_env_and_respond(*args, **kwargs):
assert os.environ.get('HTTPS_PROXY') == '192.168.50.100:1234'
assert os.environ.get('HTTP_PROXY') == '192.168.50.100:5678'
return mock.Mock(status_code=200)
mock_client_instance.make_request.side_effect = _check_env_and_respond
response = view._send_to_analytics(request, 'POST')
assert response.status_code == 200
mock_client_instance.make_request.assert_called_once()
@pytest.mark.django_db
def test__send_to_analytics_respects_proxy_env_basic_auth(self):
settings_map = {
'INSIGHTS_TRACKING_STATE': True,
'AUTOMATION_ANALYTICS_URL': 'https://example.com',
'REDHAT_USERNAME': 'redhat_user',
'REDHAT_PASSWORD': 'redhat_pass',
'SUBSCRIPTIONS_CLIENT_ID': '',
'SUBSCRIPTIONS_CLIENT_SECRET': '',
'AWX_TASK_ENV': {'HTTPS_PROXY': '192.168.50.100:1234'},
}
with override_settings(**settings_map):
request = RequestFactory().post('/some/path')
view = AnalyticsGenericView()
with mock.patch('awx.api.views.analytics.OIDCClient') as mock_oidc_client, mock.patch(
'awx.api.views.analytics.AnalyticsGenericView._base_auth_request'
) as mock_base_auth:
mock_client_instance = mock.Mock()
mock_oidc_client.return_value = mock_client_instance
mock_client_instance.make_request.side_effect = requests.RequestException("OIDC failed")
def _check_env_and_respond(*args, **kwargs):
assert os.environ.get('HTTPS_PROXY') == '192.168.50.100:1234'
return mock.Mock(status_code=200)
mock_base_auth.side_effect = _check_env_and_respond
response = view._send_to_analytics(request, 'POST')
assert response.status_code == 200
mock_base_auth.assert_called_once()
@pytest.mark.django_db
def test__send_to_analytics_restores_env_after_request(self):
original_value = os.environ.pop('HTTPS_PROXY', None)
settings_map = {
'INSIGHTS_TRACKING_STATE': True,
'AUTOMATION_ANALYTICS_URL': 'https://example.com',
'REDHAT_USERNAME': 'redhat_user',
'REDHAT_PASSWORD': 'redhat_pass',
'SUBSCRIPTIONS_CLIENT_ID': '',
'SUBSCRIPTIONS_CLIENT_SECRET': '',
'AWX_TASK_ENV': {'HTTPS_PROXY': '192.168.50.100:1234'},
}
try:
with override_settings(**settings_map):
request = RequestFactory().post('/some/path')
view = AnalyticsGenericView()
with mock.patch('awx.api.views.analytics.OIDCClient') as mock_oidc_client:
mock_client_instance = mock.Mock()
mock_oidc_client.return_value = mock_client_instance
mock_client_instance.make_request.return_value = mock.Mock(status_code=200)
view._send_to_analytics(request, 'POST')
assert 'HTTPS_PROXY' not in os.environ
finally:
if original_value is not None:
os.environ['HTTPS_PROXY'] = original_value

View File

@@ -1,3 +1,5 @@
# TODO: As of writing this our only concern is ensuring that the fact feature is reflected in the Host endpoint.
# Other host tests should live here to make this test suite more complete.
import pytest
import urllib.parse
@@ -18,48 +20,6 @@ def inventory_structure():
Group.objects.create(name="g3", inventory=inv)
@pytest.fixture
def host_filter_inventory():
"""Inventory with hosts and groups matching the tower-qa test_host_filter structure.
Groups: groupA (contains groupAA as child), groupAA, groupB
Hosts: hostA (in groupA), hostAA (in groupAA), hostB (in groupB), hostDup (in all 3 groups)
"""
org = Organization.objects.create(name="hf-org")
inv = Inventory.objects.create(name="hf-inv", organization=org)
groupA = Group.objects.create(name="groupA", inventory=inv)
groupAA = Group.objects.create(name="groupAA", inventory=inv)
groupB = Group.objects.create(name="groupB", inventory=inv)
hostA = Host.objects.create(name="hostA", inventory=inv)
hostAA = Host.objects.create(name="hostAA", inventory=inv)
hostB = Host.objects.create(name="hostB", inventory=inv)
hostDup = Host.objects.create(name="hostDup", inventory=inv)
groupA.hosts.add(hostA, hostDup)
groupAA.hosts.add(hostAA, hostDup)
groupB.hosts.add(hostB, hostDup)
groupA.children.add(groupAA)
return {
'org': org,
'inv': inv,
'hosts': {'hostA': hostA, 'hostAA': hostAA, 'hostB': hostB, 'hostDup': hostDup},
'groups': {'groupA': groupA, 'groupAA': groupAA, 'groupB': groupB},
}
def get_host_names(response):
return sorted(h['name'] for h in response.data['results'])
def host_filter_get(get, user, host_filter):
url = reverse('api:host_list')
params = "?host_filter=%s" % urllib.parse.quote(host_filter, safe='')
return get(url + params, user)
@pytest.mark.django_db
def test_q1(inventory_structure, get, user):
def evaluate_query(query, expected_hosts):
@@ -90,184 +50,3 @@ def test_q1(inventory_structure, get, user):
# The following test verifies if the search in host_filter is case insensitive.
query = 'search="HOST1"'
evaluate_query(query, [hosts[0]])
# --- Host filter query tests (migrated from tower-qa test_host_filter.py) ---
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("name=hostA", ["hostA"]),
("name=not_found", []),
("name=hostDup", ["hostDup"]),
],
)
def test_basic_host_name_search(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("name=hostA or name=hostB", ["hostA", "hostB"]),
("name=hostA or name=not_found", ["hostA"]),
("name=not_found or name=not_found", []),
("name=hostA or name=hostA", ["hostA"]),
("name=hostDup or name=hostDup", ["hostDup"]),
("name=hostA or name=hostAA or name=not_found", ["hostA", "hostAA"]),
],
)
def test_host_name_search_with_or(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("name=hostA and name=hostB", []),
("name=hostA and name=hostA", ["hostA"]),
("name=not_found and name=not_found", []),
("name=hostDup and name=hostDup", ["hostDup"]),
("name=hostA and name=hostB and name=not_found", []),
],
)
def test_host_name_search_with_and(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("groups__name=groupA", ["hostA", "hostDup"]),
("groups__name=groupAA", ["hostAA", "hostDup"]),
("groups__name=not_found", []),
],
)
def test_basic_group_search(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("groups__name=groupA or groups__name=groupB", ["hostA", "hostB", "hostDup"]),
("groups__name=groupA or groups__name=not_found", ["hostA", "hostDup"]),
("groups__name=not_found or groups__name=not_found", []),
("groups__name=groupA or groups__name=groupA", ["hostA", "hostDup"]),
(
"groups__name=groupA or groups__name=groupAA or groups__name=not_found",
["hostA", "hostAA", "hostDup"],
),
],
)
def test_group_search_with_or(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("groups__name=groupA and groups__name=groupB", ["hostDup"]),
("groups__name=groupA and groups__name=groupA", ["hostA", "hostDup"]),
("groups__name=not_found and groups__name=not_found", []),
("groups__name=groupA and groups__name=groupB and groups__name=not_found", []),
],
)
def test_group_search_with_and(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
@pytest.mark.parametrize(
"host_filter, expected",
[
("name=hostA or groups__name=groupB", ["hostA", "hostB", "hostDup"]),
("name=hostA and groups__name=groupA", ["hostA"]),
("name=hostA and groups__name=not_found", []),
("name=not_found and groups__name=not_found", []),
("name=hostDup and groups__name=groupA", ["hostDup"]),
("name=hostDup and groups__name=groupB", ["hostDup"]),
],
)
def test_basic_hybrid_search(host_filter_inventory, get, admin_user, host_filter, expected):
response = host_filter_get(get, admin_user, host_filter)
assert response.status_code == 200
assert get_host_names(response) == sorted(expected)
@pytest.mark.django_db
def test_smart_search(get, admin_user):
org = Organization.objects.create(name="search-org")
inv = Inventory.objects.create(name="search-inv", organization=org)
host = Host.objects.create(name="unique_search_target", description="findme_description", inventory=inv)
for search_term in ["unique_search_target", "findme_description"]:
response = host_filter_get(get, admin_user, "search=%s" % search_term)
assert response.status_code == 200
names = get_host_names(response)
assert host.name in names
@pytest.mark.django_db
def test_password_field_filter_blocked(get, admin_user):
url = reverse('api:host_list')
filters = [
"created_by__password__icontains=pas3w3rd",
"search=foo or created_by__password__icontains=pas3w3rd",
"created_by__password__icontains=passw3rd or search=foo",
]
for f in filters:
params = "?host_filter=%s" % urllib.parse.quote(f, safe='')
response = get(url + params, admin_user)
assert response.status_code == 400, f"Expected 400 for filter: {f}"
@pytest.mark.django_db
def test_unicode_host_filter(get, admin_user):
org = Organization.objects.create(name="unicode-org")
inv = Inventory.objects.create(name="unicode-inv", organization=org)
host = Host.objects.create(name="ホスト", inventory=inv)
group = Group.objects.create(name="グループ", inventory=inv)
group.hosts.add(host)
response = host_filter_get(get, admin_user, "name=ホスト")
assert response.status_code == 200
assert len(response.data['results']) == 1
assert response.data['results'][0]['id'] == host.id
response = host_filter_get(get, admin_user, "groups__name=グループ")
assert response.status_code == 200
assert len(response.data['results']) == 1
assert response.data['results'][0]['id'] == host.id
@pytest.mark.django_db
@pytest.mark.parametrize(
"invalid_filter",
["string_without_equals", "1", "1.0", "true"],
ids=["bare_string", "integer", "float", "bool"],
)
def test_invalid_host_filter(get, admin_user, invalid_filter):
url = reverse('api:host_list')
params = "?host_filter=%s" % urllib.parse.quote(invalid_filter, safe='')
response = get(url + params, admin_user)
assert response.status_code == 400

View File

@@ -139,7 +139,6 @@ def test_survey_password_default(post, patch, admin_user, project, inventory, su
("DTSTART:20300308T050000Z", "One or more rule required in rrule"),
("DTSTART:20300308T050000Z RRULE:FREQ=MONTHLY;INTERVAL=1; EXDATE:20220401", "EXDATE not allowed in rrule"),
("DTSTART:20300308T050000Z RRULE:FREQ=MONTHLY;INTERVAL=1; RDATE:20220401", "RDATE not allowed in rrule"),
("DTSTART:20300308T050000Z RRULE:FREQ=YEARLY;INTERVAL=0;BYDAY=MO", "INTERVAL must be a positive integer"),
("DTSTART:20300308T050000Z RRULE:FREQ=SECONDLY;INTERVAL=5;COUNT=6", "SECONDLY is not supported"),
# Individual rule test
("DTSTART:20300308T050000Z RRULE:NONSENSE", "INTERVAL required in rrule"),
@@ -203,7 +202,6 @@ def test_multiple_invalid_rrules(post, admin_user, project, inventory):
"rrule": [
"Multiple DTSTART is not supported.",
"INTERVAL required in rrule: RULE:FREQ=SECONDLY",
"SECONDLY is not supported: RULE:FREQ=SECONDLY",
"RRULE may not contain both COUNT and UNTIL: RULE:FREQ=MINUTELY;INTERVAL=10;COUNT=5;UNTIL=20220101",
"rrule parsing failed validation: 'NoneType' object has no attribute 'group'",
]

View File

@@ -1,191 +0,0 @@
import json
import pytest
from awx.api.versioning import reverse
from awx.main.models import Organization, Host, Group, Inventory
@pytest.fixture
def smart_inv_org():
return Organization.objects.create(name="smart-org")
@pytest.fixture
def smart_inv_source(smart_inv_org):
inv = Inventory.objects.create(name="smart-source-inv", organization=smart_inv_org)
Host.objects.create(name="hostA", inventory=inv)
Host.objects.create(name="hostB", inventory=inv)
Host.objects.create(name="hostDup", inventory=inv)
groupA = Group.objects.create(name="groupA", inventory=inv)
groupB = Group.objects.create(name="groupB", inventory=inv)
groupA.hosts.add(*inv.hosts.filter(name__in=["hostA", "hostDup"]))
groupB.hosts.add(*inv.hosts.filter(name__in=["hostB", "hostDup"]))
return inv
@pytest.mark.django_db
def test_create_smart_inventory(post, admin_user, smart_inv_org):
resp = post(
reverse('api:inventory_list'),
{
'name': 'my-smart-inv',
'kind': 'smart',
'organization': smart_inv_org.pk,
'host_filter': 'name=hostA',
},
admin_user,
expect=201,
)
assert resp.data['kind'] == 'smart'
assert resp.data['host_filter'] == 'name=hostA'
@pytest.mark.django_db
def test_create_smart_inventory_requires_host_filter(post, admin_user, smart_inv_org):
resp = post(
reverse('api:inventory_list'),
{
'name': 'no-filter-smart',
'kind': 'smart',
'organization': smart_inv_org.pk,
},
admin_user,
expect=400,
)
assert 'host_filter' in json.dumps(resp.data)
@pytest.mark.django_db
def test_unable_to_create_host_in_smart_inventory(post, admin_user, smart_inv_org):
smart_inv = Inventory.objects.create(
name="no-host-create",
kind="smart",
host_filter="name=hostA",
organization=smart_inv_org,
)
url = reverse('api:inventory_hosts_list', kwargs={'pk': smart_inv.pk})
resp = post(url, {'name': 'new-host'}, admin_user, expect=400)
assert 'Cannot create' in json.dumps(resp.data)
@pytest.mark.django_db
def test_unable_to_create_group_in_smart_inventory(post, admin_user, smart_inv_org):
smart_inv = Inventory.objects.create(
name="no-group-create",
kind="smart",
host_filter="name=hostA",
organization=smart_inv_org,
)
url = reverse('api:inventory_groups_list', kwargs={'pk': smart_inv.pk})
resp = post(url, {'name': 'new-group'}, admin_user, expect=400)
assert 'Cannot create' in json.dumps(resp.data)
@pytest.mark.django_db
def test_unable_to_create_inventory_source_in_smart_inventory(post, admin_user, smart_inv_org):
smart_inv = Inventory.objects.create(
name="no-src-create",
kind="smart",
host_filter="name=hostA",
organization=smart_inv_org,
)
url = reverse('api:inventory_inventory_sources_list', kwargs={'pk': smart_inv.pk})
resp = post(url, {'name': 'new-src', 'source': 'ec2'}, admin_user, expect=400)
assert 'Cannot create' in json.dumps(resp.data)
@pytest.mark.django_db
def test_convert_smart_to_regular_inventory(admin_user, smart_inv_org):
smart_inv = Inventory.objects.create(
name="convert-to-regular",
kind="smart",
host_filter="name=anything",
organization=smart_inv_org,
)
assert smart_inv.kind == 'smart'
smart_inv.host_filter = ''
smart_inv.kind = ''
smart_inv.save()
smart_inv.refresh_from_db()
assert smart_inv.kind == ''
assert not smart_inv.host_filter
@pytest.mark.django_db
def test_smart_inventory_deletion_does_not_cascade(admin_user, smart_inv_source, smart_inv_org):
host = smart_inv_source.hosts.first()
smart_inv = Inventory.objects.create(
name="delete-no-cascade",
kind="smart",
host_filter="name=%s" % host.name,
organization=smart_inv_org,
)
smart_inv.delete()
assert Host.objects.filter(pk=host.pk).exists()
@pytest.mark.django_db
def test_urlencode_host_filter(post, admin_user, smart_inv_org):
post(
reverse('api:inventory_list'),
data={
'name': 'url-encoded-smart',
'kind': 'smart',
'organization': smart_inv_org.pk,
'host_filter': 'ansible_facts__ansible_distribution_version=%227.4%22',
},
user=admin_user,
expect=201,
)
si = Inventory.objects.get(name='url-encoded-smart')
assert si.host_filter == 'ansible_facts__ansible_distribution_version="7.4"'
@pytest.mark.django_db
def test_host_filter_unicode(post, admin_user, smart_inv_org):
post(
reverse('api:inventory_list'),
data={
'name': 'unicode-smart',
'kind': 'smart',
'organization': smart_inv_org.pk,
'host_filter': u'ansible_facts__ansible_distribution=レッドハット',
},
user=admin_user,
expect=201,
)
si = Inventory.objects.get(name='unicode-smart')
assert si.host_filter == u'ansible_facts__ansible_distribution=レッドハット'
@pytest.mark.django_db
@pytest.mark.parametrize("lookup", ['icontains', 'has_keys'])
def test_host_filter_invalid_ansible_facts_lookup(post, admin_user, smart_inv_org, lookup):
resp = post(
reverse('api:inventory_list'),
data={
'name': 'invalid-lookup-smart',
'kind': 'smart',
'organization': smart_inv_org.pk,
'host_filter': u'ansible_facts__ansible_distribution__{}=cent'.format(lookup),
},
user=admin_user,
expect=400,
)
assert 'ansible_facts does not support searching with __{}'.format(lookup) in json.dumps(resp.data)
@pytest.mark.django_db
def test_host_filter_ansible_facts_exact(post, admin_user, smart_inv_org):
post(
reverse('api:inventory_list'),
data={
'name': 'exact-smart',
'kind': 'smart',
'organization': smart_inv_org.pk,
'host_filter': 'ansible_facts__ansible_distribution__exact="CentOS"',
},
user=admin_user,
expect=201,
)

View File

@@ -145,3 +145,124 @@ def test_delete_ad_hoc_command_in_active_state(ad_hoc_command_factory, delete, a
adhoc = ad_hoc_command_factory(initial_state=status)
url = reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk})
delete(url, None, admin, expect=403)
@pytest.fixture
def job_with_heavy_fields(job_factory):
job = job_factory()
job.extra_vars = '{"some_var": "some_value"}'
job.artifacts = {"some_artifact": "some_value"}
job.save()
return job
def _job_result(response, job_id):
for row in response.data['results']:
if row['id'] == job_id:
return row
raise AssertionError('job {} not found in {}'.format(job_id, [r['id'] for r in response.data['results']]))
@pytest.mark.django_db
def test_unified_jobs_list_strips_heavy_fields_by_default(get, admin, job_with_heavy_fields):
response = get(reverse('api:unified_job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' not in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_unified_jobs_list_include_artifacts(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&include=artifacts'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_unified_jobs_list_include_extra_vars(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&include=extra_vars'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'extra_vars' in row
assert 'artifacts' not in row
@pytest.mark.django_db
def test_unified_jobs_list_include_both(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&include=artifacts,extra_vars'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' in row
assert 'extra_vars' in row
@pytest.mark.django_db
def test_unified_jobs_list_include_tolerates_whitespace(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&include=%20artifacts%20,%20extra_vars%20'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' in row
assert 'extra_vars' in row
@pytest.mark.django_db
def test_unified_jobs_list_include_ignores_unknown(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&include=does_not_exist'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' not in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_unified_jobs_list_include_does_not_honor_disallowed(get, admin, job_with_heavy_fields):
# event_processing_finished triggers a count(*) on main_jobevent and must
# not be re-enabled via the public ?include= param.
response = get(
reverse('api:unified_job_list') + '?id={}&include=event_processing_finished,job_args,result_traceback'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'event_processing_finished' not in row
assert 'job_args' not in row
assert 'result_traceback' not in row
assert 'artifacts' not in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_jobs_list_strips_heavy_fields_by_default(get, admin, job_with_heavy_fields):
response = get(reverse('api:job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' not in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_jobs_list_include_extra_vars(get, admin, job_with_heavy_fields):
response = get(
reverse('api:job_list') + '?id={}&include=extra_vars'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'extra_vars' in row
assert 'artifacts' not in row

View File

@@ -1,240 +0,0 @@
import pytest
from awx.api.versioning import reverse
from awx.main.models import NotificationTemplate, Organization
from ansible_base.rbac.models import RoleDefinition
from ansible_base.rbac import permission_registry
NT_DATA = {
'notification_type': 'webhook',
'notification_configuration': {
'url': 'http://localhost',
'username': '',
'password': '',
'headers': {},
},
}
def nt_url(pk):
return reverse('api:notification_template_detail', kwargs={'pk': pk})
@pytest.fixture
def nt_add_role(setup_managed_roles):
"""A custom role with only add_notificationtemplate and view_organization.
This is intentionally narrower than Organization NotificationTemplate Admin
so that give_creator_permissions actually creates creator permissions."""
rd, _ = RoleDefinition.objects.get_or_create(
name='nt-add-only',
permissions=['add_notificationtemplate', 'view_organization'],
content_type=permission_registry.content_type_model.objects.get_for_model(Organization),
)
return rd
@pytest.mark.django_db
def test_create_with_add_only_role_gets_creator_permissions(rando, organization, post, get, patch, nt_add_role):
"""User with only add permission creates a notification template and gets
creator permissions (change, delete, view) via give_creator_permissions.
This exercises the fix for models without old-style roles (AAP-57274)."""
nt_add_role.give_permission(rando, organization)
r = post(
reverse('api:notification_template_list'),
dict(name='rando-nt', organization=organization.id, **NT_DATA),
user=rando,
expect=201,
)
nt = NotificationTemplate.objects.get(pk=r.data['id'])
assert rando.has_obj_perm(nt, 'change')
assert rando.has_obj_perm(nt, 'view')
# Creator permissions survive revocation of the org-level add role
nt_add_role.remove_permission(rando, organization)
get(nt_url(nt.pk), user=rando, expect=200)
patch(nt_url(nt.pk), data={'description': 'updated'}, user=rando, expect=200)
@pytest.mark.django_db
def test_org_admin_can_crud(rando, organization, post, get, patch, delete, setup_managed_roles):
"""User with org-level notification admin can create, view, edit, and delete"""
rd = RoleDefinition.objects.get(name='Organization NotificationTemplate Admin')
rd.give_permission(rando, organization)
r = post(
reverse('api:notification_template_list'),
dict(name='rando-nt', organization=organization.id, **NT_DATA),
user=rando,
expect=201,
)
pk = r.data['id']
url = nt_url(pk)
get(url, user=rando, expect=200)
patch(url, data={'description': 'updated'}, user=rando, expect=200)
delete(url, user=rando, expect=204)
assert not NotificationTemplate.objects.filter(pk=pk).exists()
@pytest.mark.django_db
def test_unpermissioned_user_cannot_access(rando, notification_template, get, patch, delete, setup_managed_roles):
"""User without any permissions cannot view, edit, or delete a notification template"""
url = nt_url(notification_template.pk)
get(url, user=rando, expect=403)
patch(url, data={'description': 'nope'}, user=rando, expect=403)
delete(url, user=rando, expect=403)
@pytest.mark.django_db
def test_grant_and_revoke_object_role(rando, notification_template, get, patch, setup_managed_roles):
"""Granting and revoking NotificationTemplate Admin role controls access"""
rd = RoleDefinition.objects.get(name='NotificationTemplate Admin')
url = nt_url(notification_template.pk)
get(url, user=rando, expect=403)
rd.give_permission(rando, notification_template)
get(url, user=rando, expect=200)
patch(url, data={'description': 'changed'}, user=rando, expect=200)
rd.remove_permission(rando, notification_template)
get(url, user=rando, expect=403)
patch(url, data={'description': 'nope'}, user=rando, expect=403)
@pytest.mark.django_db
def test_creator_can_access_sub_endpoints(rando, organization, post, get, nt_add_role):
"""Creator can access notification list sub-endpoint"""
nt_add_role.give_permission(rando, organization)
r = post(
reverse('api:notification_template_list'),
dict(name='rando-nt', organization=organization.id, **NT_DATA),
user=rando,
expect=201,
)
pk = r.data['id']
# Revoke org-level role so only creator permissions remain
nt_add_role.remove_permission(rando, organization)
get(
reverse('api:notification_template_notification_list', kwargs={'pk': pk}),
user=rando,
expect=200,
)
@pytest.mark.django_db
def test_list_filtered_by_permissions(rando, admin_user, organization, post, get, nt_add_role):
"""Notification template list only shows templates the user has access to"""
nt_add_role.give_permission(rando, organization)
post(
reverse('api:notification_template_list'),
dict(name='admin-nt', organization=organization.id, **NT_DATA),
user=admin_user,
expect=201,
)
post(
reverse('api:notification_template_list'),
dict(name='rando-nt', organization=organization.id, **NT_DATA),
user=rando,
expect=201,
)
# rando has org-level add, but admin-nt was created by admin → rando shouldn't see it
# unless org admin role also gives view. With add-only role, rando has view_organization
# but not view_notificationtemplate at the org level, so they only see their own (via creator perms)
nt_add_role.remove_permission(rando, organization)
r = get(reverse('api:notification_template_list'), user=rando, expect=200)
visible_names = {item['name'] for item in r.data['results']}
assert 'rando-nt' in visible_names
assert 'admin-nt' not in visible_names
@pytest.mark.django_db
def test_creator_access_list_with_add_only_role(rando, organization, post, get, nt_add_role):
"""User with add_only role creates a notification template and can access its access_list endpoint"""
from ansible_base.rbac.models import DABContentType
nt_add_role.give_permission(rando, organization)
r = post(
reverse('api:notification_template_list'),
dict(name='rando-nt', organization=organization.id, **NT_DATA),
user=rando,
expect=201,
)
nt = NotificationTemplate.objects.get(pk=r.data['id'])
# Revoke org-level role so only creator permissions remain
nt_add_role.remove_permission(rando, organization)
# Creator should be able to access the access_list endpoint for their own notification template
# Use the DAB access_list endpoint pattern: /api/v2/role_user_access/{model_name}/{pk}/
ct = DABContentType.objects.get_for_model(NotificationTemplate)
access_list_url = f'/api/v2/role_user_access/{ct.api_slug}/{nt.pk}/?order_by=id'
r = get(access_list_url, user=rando, expect=200)
# The creator should be listed in the access list
usernames = {user['username'] for user in r.data['results']}
assert rando.username in usernames
@pytest.mark.django_db
def test_unpermissioned_user_cannot_access_access_list(rando, organization, post, admin_user, get, setup_managed_roles):
"""User without view permission cannot access the access_list endpoint"""
from ansible_base.rbac.models import DABContentType
# Create a notification template as admin
r = post(
reverse('api:notification_template_list'),
dict(name='admin-nt', organization=organization.id, **NT_DATA),
user=admin_user,
expect=201,
)
nt = NotificationTemplate.objects.get(pk=r.data['id'])
ct = DABContentType.objects.get_for_model(NotificationTemplate)
access_list_url = f'/api/v2/role_user_access/{ct.api_slug}/{nt.pk}/?order_by=id'
# rando has no permissions on this notification template, so they can't see it or its access list
# The endpoint returns 404 (not found) instead of 403 when user can't view the resource
get(access_list_url, user=rando, expect=404)
@pytest.mark.django_db
def test_access_list_shows_creator(rando, organization, post, get, nt_add_role, setup_managed_roles):
"""Access list shows the creator with direct permissions"""
from ansible_base.rbac.models import DABContentType
from ansible_base.rbac.models import RoleDefinition
nt_add_role.give_permission(rando, organization)
# rando creates a notification template
r = post(
reverse('api:notification_template_list'),
dict(name='rando-nt', organization=organization.id, **NT_DATA),
user=rando,
expect=201,
)
nt = NotificationTemplate.objects.get(pk=r.data['id'])
# Now assign them the object admin role directly too
rd = RoleDefinition.objects.get(name='NotificationTemplate Admin')
rd.give_permission(rando, nt)
ct = DABContentType.objects.get_for_model(NotificationTemplate)
access_list_url = f'/api/v2/role_user_access/{ct.api_slug}/{nt.pk}/?order_by=id'
r = get(access_list_url, user=rando, expect=200)
# rando should be listed with direct permissions from both creator and object role assignment
user_data = {item['username']: item for item in r.data['results']}
assert rando.username in user_data
# Verify they have direct role assignments
assert len(user_data[rando.username]['object_role_assignments']) > 0
assert any(assign.get('type') == 'direct' for assign in user_data[rando.username]['object_role_assignments'])

View File

@@ -173,22 +173,6 @@ def test_creator_permission(rando, admin_user, inventory, setup_managed_roles):
assert rando in inventory.admin_role.members.all()
@pytest.mark.django_db
def test_creator_permission_notification_template(rando, organization, setup_managed_roles):
"""NotificationTemplate has no old-style roles, give_creator_permissions should not error"""
from awx.main.models import NotificationTemplate
nt = NotificationTemplate.objects.create(
name='test-nt',
organization=organization,
notification_type='slack',
notification_configuration={'token': 'x', 'channels': ['#test']},
)
give_creator_permissions(rando, nt)
assignment = RoleUserAssignment.objects.filter(user=rando, object_id=nt.pk).first()
assert assignment is not None
@pytest.mark.django_db
def test_implicit_parents_no_assignments(organization):
"""Through the normal course of creating models, we should not be changing DAB RBAC permissions"""

View File

@@ -8,7 +8,7 @@ from awx.main.management.commands.dispatcherd import _hash_config
def test_dispatcherd_config_hash_is_stable(settings, monkeypatch):
monkeypatch.setenv('AWX_COMPONENT', 'dispatcher')
settings.CLUSTER_HOST_ID = 'test-node'
settings.DISPATCHER_MIN_WORKERS = 1
settings.JOB_EVENT_WORKERS = 1
settings.DISPATCHER_SCHEDULE = {}
config_one = get_dispatcherd_config(for_service=True)

View File

@@ -1,8 +1,7 @@
import pytest
# AWX context managers for testing
from awx.main.signals import disable_activity_stream, disable_computed_fields
from awx.main.tasks.system import update_inventory_computed_fields
from awx.main.signals import disable_activity_stream, disable_computed_fields, update_inventory_computed_fields
# AWX models
from awx.main.models.organization import Organization

View File

@@ -1,13 +1,7 @@
import urllib.parse
import pytest
from awx.api.versioning import reverse
from awx.main.models import (
Group,
Host,
Inventory,
Organization,
Schedule,
)
from awx.main.access import (
@@ -134,94 +128,3 @@ class TestSmartInventory:
assert InventoryAccess(org_admin).can_admin(smart_inventory, {'host_filter': 'search=foo'})
smart_inventory.admin_role.members.add(rando)
assert not InventoryAccess(rando).can_admin(smart_inventory, {'host_filter': 'search=foo'})
def test_host_filter_edit_unprivileged(self, smart_inventory, user):
unprivileged = user('unprivileged', False)
assert not InventoryAccess(unprivileged).can_change(smart_inventory, None)
assert not InventoryAccess(unprivileged).can_admin(smart_inventory, {'host_filter': 'search=bar'})
def test_host_filter_edit_inventory_admin_role(self, smart_inventory, user):
inv_admin = user('inv_admin', False)
smart_inventory.admin_role.members.add(inv_admin)
assert InventoryAccess(inv_admin).can_change(smart_inventory, None)
assert not InventoryAccess(inv_admin).can_admin(smart_inventory, {'host_filter': 'search=bar'})
def test_host_filter_edit_org_admin_via_api(self, smart_inventory, patch, user):
oa = user('smart_oa', False)
smart_inventory.organization.admin_role.members.add(oa)
url = reverse('api:inventory_detail', kwargs={'pk': smart_inventory.pk})
resp = patch(url, {'host_filter': 'search=bar'}, oa, expect=200)
assert resp.data['host_filter'] == 'search=bar'
@pytest.mark.parametrize("role_field", ['admin_role', 'use_role', 'adhoc_role', 'read_role'])
def test_inventory_role_cannot_edit_host_filter(self, smart_inventory, patch, user, role_field):
u = user('role_test_user', False)
getattr(smart_inventory, role_field).members.add(u)
url = reverse('api:inventory_detail', kwargs={'pk': smart_inventory.pk})
patch(url, {'host_filter': 'search=bar'}, u, expect=403)
@pytest.mark.django_db
class TestHostFilterRBAC:
@pytest.fixture
def two_org_inventories(self):
orgA = Organization.objects.create(name="rbac-orgA")
orgB = Organization.objects.create(name="rbac-orgB")
invA = Inventory.objects.create(name="rbac-invA", organization=orgA)
invB = Inventory.objects.create(name="rbac-invB", organization=orgB)
hostA = Host.objects.create(name="shared_name", inventory=invA)
hostB = Host.objects.create(name="shared_name", inventory=invB)
groupA = Group.objects.create(name="shared_group", inventory=invA)
groupB = Group.objects.create(name="shared_group", inventory=invB)
groupA.hosts.add(hostA)
groupB.hosts.add(hostB)
return {
'orgA': orgA,
'orgB': orgB,
'invA': invA,
'invB': invB,
'hostA': hostA,
'hostB': hostB,
}
@pytest.mark.parametrize("host_filter", ["name=shared_name", "groups__name=shared_group"])
def test_host_filter_scoped_to_inventory_read_role(self, two_org_inventories, get, user, host_filter):
data = two_org_inventories
userA = user('rbac_userA', False)
userB = user('rbac_userB', False)
data['invA'].read_role.members.add(userA)
data['invB'].read_role.members.add(userB)
url = reverse('api:host_list')
params = "?host_filter=%s" % urllib.parse.quote(host_filter, safe='')
respA = get(url + params, userA)
idsA = [h['id'] for h in respA.data['results']]
assert data['hostA'].id in idsA
assert data['hostB'].id not in idsA
respB = get(url + params, userB)
idsB = [h['id'] for h in respB.data['results']]
assert data['hostB'].id in idsB
assert data['hostA'].id not in idsB
@pytest.mark.parametrize("host_filter", ["name=shared_name", "groups__name=shared_group"])
def test_host_filter_scoped_to_org_admin(self, two_org_inventories, get, user, host_filter):
data = two_org_inventories
adminA = user('rbac_adminA', False)
adminB = user('rbac_adminB', False)
data['orgA'].admin_role.members.add(adminA)
data['orgB'].admin_role.members.add(adminB)
url = reverse('api:host_list')
params = "?host_filter=%s" % urllib.parse.quote(host_filter, safe='')
respA = get(url + params, adminA)
idsA = [h['id'] for h in respA.data['results']]
assert data['hostA'].id in idsA
assert data['hostB'].id not in idsA
respB = get(url + params, adminB)
idsB = [h['id'] for h in respB.data['results']]
assert data['hostB'].id in idsB
assert data['hostA'].id not in idsB

View File

@@ -1,9 +1,6 @@
import pytest
from django.apps import apps
from django.core.management.base import CommandError
from awx.main.tasks.system import _sync_credential_types_to_db
@pytest.fixture
@@ -12,38 +9,18 @@ def mock_setup_tower_managed_defaults(mocker):
@pytest.mark.django_db
def test_sync_credential_types_migrations_ran(mocker, mock_setup_tower_managed_defaults):
mocker.patch('awx.main.tasks.system.is_database_synchronized', return_value=True)
def test_load_credential_types_feature_migrations_ran(mocker, mock_setup_tower_managed_defaults):
mocker.patch('awx.main.apps.is_database_synchronized', return_value=True)
_sync_credential_types_to_db()
apps.get_app_config('main')._load_credential_types_feature()
mock_setup_tower_managed_defaults.assert_called_once()
@pytest.mark.django_db
def test_sync_credential_types_migrations_not_ran(mocker, mock_setup_tower_managed_defaults):
mocker.patch('awx.main.tasks.system.is_database_synchronized', return_value=False)
def test_load_credential_types_feature_migrations_not_ran(mocker, mock_setup_tower_managed_defaults):
mocker.patch('awx.main.apps.is_database_synchronized', return_value=False)
_sync_credential_types_to_db()
apps.get_app_config('main')._load_credential_types_feature()
mock_setup_tower_managed_defaults.assert_not_called()
def test_check_db_requirement_no_violations(mocker):
mocker.patch('awx.main.apps.db_requirement_violations', return_value=None)
main_config = apps.get_app_config('main')
result = main_config.check_db_requirement()
assert result is None
def test_check_db_requirement_with_violations(mocker):
violation_msg = "Database version check failed"
mocker.patch('awx.main.apps.db_requirement_violations', return_value=violation_msg)
main_config = apps.get_app_config('main')
with pytest.raises(CommandError) as exc_info:
main_config.check_db_requirement()
assert str(exc_info.value) == violation_msg

View File

@@ -160,38 +160,3 @@ class TestJobReaper(object):
assert job.started > ref_time
assert job.status == 'running'
assert job.job_explanation == ''
def test_waiting_job_reset_when_controller_node_deprovisioned(self):
"""When a controller pod is replaced (e.g. K8s rollout), waiting jobs
assigned to the now-gone controller_node should be reset to pending
by the task manager so they can be re-dispatched."""
from awx.main.scheduler import TaskManager
live_inst = Instance(hostname='awx-task-live', node_type='control')
live_inst.save()
# No instance record for 'awx-task-dead' — it was already deprovisioned
job = Job.objects.create(status='waiting', controller_node='awx-task-dead', execution_node='')
tm = TaskManager()
tm.reap_jobs_from_orphaned_instances()
job.refresh_from_db()
assert job.status == 'pending'
assert job.controller_node == ''
assert job.execution_node == ''
@pytest.mark.parametrize('node_type', ['control', 'hybrid'])
def test_waiting_job_not_reset_when_controller_node_alive(self, node_type):
"""Waiting jobs on a live control or hybrid node should not be touched."""
from awx.main.scheduler import TaskManager
live_inst = Instance(hostname='awx-task-live', node_type=node_type)
live_inst.save()
job = Job.objects.create(status='waiting', controller_node='awx-task-live', execution_node='')
tm = TaskManager()
tm.reap_jobs_from_orphaned_instances()
job.refresh_from_db()
assert job.status == 'waiting'
assert job.controller_node == 'awx-task-live'

View File

@@ -287,20 +287,6 @@ def test_control_plane_policy_exception(controlplane_instance_group):
assert 'foo-1' not in [inst.hostname for inst in controlplane_instance_group.instances.all()]
@pytest.mark.django_db
def test_policy_instance_list_controlplane_excludes_execution_node(controlplane_instance_group):
controlplane_instance_group.policy_instance_percentage = 100
controlplane_instance_group.save()
exec_inst = Instance.objects.create(hostname='exec-1', node_type='execution')
control_inst = Instance.objects.create(hostname='control-1', node_type='control')
controlplane_instance_group.policy_instance_list = [exec_inst.hostname]
controlplane_instance_group.save()
apply_cluster_membership_policies()
members = list(controlplane_instance_group.instances.all())
assert exec_inst not in members
assert control_inst in members
@pytest.mark.django_db
def test_normal_instance_group_policy_exception():
ig = InstanceGroup.objects.create(name='bar', policy_instance_percentage=100, policy_instance_minimum=2)

View File

@@ -1,320 +0,0 @@
"""Smart inventory tests that require PostgreSQL.
These tests exercise SmartFilter and smart inventory host resolution against
a real PostgreSQL database. Most are unit-style tests that set ansible_facts
directly on Host objects rather than running playbooks.
The smart inventory HostManager uses DISTINCT ON which requires PostgreSQL,
so any test that reads smart inventory hosts must run here (not in functional/).
"""
import pytest
from awx.main.models import Organization, Inventory, Host, Group
from awx.main.utils.filters import SmartFilter
@pytest.fixture
def fact_org():
org, _ = Organization.objects.get_or_create(name='smart-inv-fact-test-org')
return org
@pytest.fixture
def fact_inventory(fact_org):
inv, created = Inventory.objects.get_or_create(name='smart-inv-fact-test-inv', organization=fact_org)
if not created:
inv.hosts.all().delete()
inv.groups.all().delete()
groupA = Group.objects.create(name='factGroupA', inventory=inv)
groupB = Group.objects.create(name='factGroupB', inventory=inv)
hostA = Host.objects.create(
name='factHostA',
inventory=inv,
ansible_facts={
'ansible_system': 'Linux',
'ansible_distribution': 'CentOS',
'ansible_python': {
'version': {'major': 3, 'minor': 9, 'micro': 7},
'version_info': [3, 9, 7, 'final', 0],
},
'ansible_env': {'HOME': '/root'},
},
)
hostB = Host.objects.create(
name='factHostB',
inventory=inv,
ansible_facts={
'ansible_system': 'Linux',
'ansible_distribution': 'Ubuntu',
'ansible_python': {
'version': {'major': 3, 'minor': 11, 'micro': 2},
'version_info': [3, 11, 2, 'final', 0],
},
'ansible_env': {'HOME': '/home/user'},
},
)
hostC = Host.objects.create(
name='factHostC',
inventory=inv,
ansible_facts={
'ansible_system': 'Darwin',
'ansible_distribution': 'MacOSX',
'ansible_python': {
'version': {'major': 3, 'minor': 10, 'micro': 0},
'version_info': [3, 10, 0, 'final', 0],
},
'ansible_env': {'HOME': '/Users/test'},
},
)
groupA.hosts.add(hostA, hostC)
groupB.hosts.add(hostB, hostC)
yield {
'org': fact_org,
'inv': inv,
'hosts': {'hostA': hostA, 'hostB': hostB, 'hostC': hostC},
'groups': {'groupA': groupA, 'groupB': groupB},
}
hostA.delete()
hostB.delete()
hostC.delete()
groupA.delete()
groupB.delete()
@pytest.fixture
def smart_inventory_factory():
created = []
def _factory(name, host_filter, organization):
inv = Inventory.objects.create(name=name, kind='smart', host_filter=host_filter, organization=organization)
created.append(inv)
return inv
yield _factory
for inv in reversed(created):
inv.delete()
@pytest.fixture
def host_factory():
created = []
def _factory(**kwargs):
host = Host.objects.create(**kwargs)
created.append(host)
return host
yield _factory
for host in reversed(created):
if host.pk is not None:
host.delete()
@pytest.fixture
def group_factory():
created = []
def _factory(**kwargs):
group = Group.objects.create(**kwargs)
created.append(group)
return group
yield _factory
for group in reversed(created):
group.delete()
def query_names(filter_string):
return sorted(SmartFilter.query_from_string(filter_string).distinct().values_list('name', flat=True))
# --- Fact-based filter tests (require PostgreSQL for JSONField __contains) ---
def test_fact_based_host_filter(fact_inventory):
assert query_names('ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB']
assert query_names('ansible_facts__ansible_distribution=CentOS') == ['factHostA']
assert query_names('ansible_facts__ansible_distribution=Ubuntu') == ['factHostB']
assert query_names('ansible_facts__ansible_system=Darwin') == ['factHostC']
assert query_names('ansible_facts__ansible_system=Windows') == []
def test_nested_fact_search(fact_inventory):
assert query_names('ansible_facts__ansible_python__version__major=3') == ['factHostA', 'factHostB', 'factHostC']
assert query_names('ansible_facts__ansible_python__version__minor=9') == ['factHostA']
assert query_names('ansible_facts__ansible_python__version__minor=11') == ['factHostB']
assert query_names('ansible_facts__ansible_env__HOME=/root') == ['factHostA']
def test_list_fact_search(fact_inventory):
assert query_names('ansible_facts__ansible_python__version_info[]=9') == ['factHostA']
assert query_names('ansible_facts__ansible_python__version_info[]=11') == ['factHostB']
assert query_names('ansible_facts__ansible_python__version_info[]=3') == ['factHostA', 'factHostB', 'factHostC']
def test_fact_search_with_or(fact_inventory):
assert query_names('ansible_facts__ansible_system=Linux or ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB']
assert query_names('ansible_facts__ansible_system=Linux or ansible_facts__ansible_system=not_found') == ['factHostA', 'factHostB']
assert query_names('ansible_facts__ansible_system=not_found or ansible_facts__ansible_system=not_found') == []
assert query_names('ansible_facts__ansible_system=Linux or ansible_facts__ansible_system=Darwin') == ['factHostA', 'factHostB', 'factHostC']
def test_fact_search_with_and(fact_inventory):
assert query_names('ansible_facts__ansible_system=Linux and ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB']
assert query_names('ansible_facts__ansible_system=Linux and ansible_facts__ansible_system=not_found') == []
assert query_names('ansible_facts__ansible_system=Linux and ansible_facts__ansible_distribution=CentOS') == ['factHostA']
def test_hybrid_fact_name_group_search(fact_inventory):
assert query_names('name=factHostA or groups__name=factGroupB or ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB', 'factHostC']
assert query_names('name=factHostA or groups__name=factGroupA or ansible_facts__ansible_system=not_found') == ['factHostA', 'factHostC']
assert query_names('name=factHostA and groups__name=factGroupA and ansible_facts__ansible_system=not_found') == []
assert query_names('name=factHostA and groups__name=factGroupA and ansible_facts__ansible_system=Linux') == ['factHostA']
def test_advanced_hybrid_with_parentheses(fact_inventory):
assert query_names('name=factHostA or (groups__name=factGroupB and ansible_facts__ansible_system=not_found)') == ['factHostA']
assert query_names('name=not_found or (groups__name=factGroupB and ansible_facts__ansible_system=Linux)') == ['factHostB']
assert query_names('(name=factHostA or groups__name=factGroupB) and ansible_facts__ansible_system=not_found') == []
assert query_names('(name=factHostA or groups__name=factGroupB) and ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB']
assert query_names('(name=factHostC or groups__name=factGroupA) and ansible_facts__ansible_system=Darwin') == ['factHostC']
# --- Smart inventory host resolution tests (require PostgreSQL for DISTINCT ON) ---
def test_smart_inventory_hosts_by_name(fact_inventory, smart_inventory_factory):
org = fact_inventory['org']
smart_inv = smart_inventory_factory('smart-by-name', 'name=factHostA', org)
hosts = sorted(smart_inv.hosts.values_list('name', flat=True))
assert hosts == ['factHostA']
def test_smart_inventory_hosts_by_group(fact_inventory, smart_inventory_factory):
org = fact_inventory['org']
smart_inv = smart_inventory_factory('smart-by-group', 'groups__name=factGroupA', org)
hosts = sorted(smart_inv.hosts.values_list('name', flat=True))
assert hosts == ['factHostA', 'factHostC']
def test_smart_inventory_with_facts(fact_inventory, smart_inventory_factory):
org = fact_inventory['org']
smart_inv = smart_inventory_factory('fact-smart-inv', 'ansible_facts__ansible_system=Linux', org)
hosts = sorted(smart_inv.hosts.values_list('name', flat=True))
assert hosts == ['factHostA', 'factHostB']
assert smart_inv.total_hosts == 2
def test_smart_inventory_with_nested_facts(fact_inventory, smart_inventory_factory):
org = fact_inventory['org']
smart_inv = smart_inventory_factory(
'nested-fact-smart-inv',
'ansible_facts__ansible_distribution=CentOS and ansible_facts__ansible_python__version__minor=9',
org,
)
hosts = list(smart_inv.hosts.values_list('name', flat=True))
assert hosts == ['factHostA']
def test_host_filter_is_organization_scoped(fact_inventory, smart_inventory_factory, host_factory):
"""Smart inventory only includes hosts from its own organization."""
org1 = fact_inventory['org']
org2, _ = Organization.objects.get_or_create(name='smart-inv-other-org')
inv2, _ = Inventory.objects.get_or_create(name='other-org-inv', organization=org2)
Host.objects.filter(name='factHostA', inventory=inv2).delete()
host_factory(name='factHostA', inventory=inv2)
smart_inv = smart_inventory_factory('scoped-smart', 'name=factHostA', org1)
hosts = list(smart_inv.hosts.all())
assert len(hosts) == 1
assert hosts[0].inventory_id == fact_inventory['inv'].id
def test_duplicate_hosts_deduplicated(smart_inventory_factory, host_factory):
"""Same-name hosts across inventories in the same org yield only one smart inventory entry."""
org, _ = Organization.objects.get_or_create(name='smart-inv-dedup-org')
inv1, _ = Inventory.objects.get_or_create(name='dedup-inv1', organization=org)
inv2, _ = Inventory.objects.get_or_create(name='dedup-inv2', organization=org)
Host.objects.filter(name='dedup_host', inventory__in=[inv1, inv2]).delete()
host1 = host_factory(name='dedup_host', inventory=inv1)
host2 = host_factory(name='dedup_host', inventory=inv2)
smart_inv = smart_inventory_factory('dedup-smart', 'name=dedup_host', org)
hosts = list(smart_inv.hosts.all())
assert len(hosts) == 1
assert hosts[0].id == min(host1.id, host2.id)
def test_host_sources_original_inventory(fact_inventory, smart_inventory_factory):
"""Hosts in a smart inventory still reference their source inventory."""
org = fact_inventory['org']
source_inv = fact_inventory['inv']
smart_inv = smart_inventory_factory('sources-original', 'name=factHostA', org)
host = smart_inv.hosts.first()
assert host.inventory_id == source_inv.id
def test_host_updates_reflected_in_smart_inventory(fact_inventory, smart_inventory_factory, host_factory):
"""Editing or deleting a host is immediately reflected in a smart inventory."""
org = fact_inventory['org']
inv = fact_inventory['inv']
host = host_factory(name='mutable_host', inventory=inv)
smart_inv = smart_inventory_factory('updates-reflected', 'name=mutable_host', org)
assert smart_inv.hosts.count() == 1
host.description = 'updated'
host.save()
assert smart_inv.hosts.first().description == 'updated'
host.delete()
assert smart_inv.hosts.count() == 0
def test_smart_inventory_duplicate_hosts_matching_group_names(fact_inventory, smart_inventory_factory, host_factory, group_factory):
"""A host in multiple groups whose names match an icontains filter appears only once."""
org = fact_inventory['org']
inv = fact_inventory['inv']
g1 = group_factory(name='dedup_another_group', inventory=inv)
g2 = group_factory(name='dedup_yet_another_group', inventory=inv)
host = host_factory(name='dedup_grouped_host', inventory=inv)
g1.hosts.add(host)
g2.hosts.add(host)
smart_inv = smart_inventory_factory('group-dedup-smart', 'groups__name__icontains=dedup_another', org)
assert smart_inv.hosts.count() == 1
def test_smart_inventory_computed_fields(fact_inventory, smart_inventory_factory):
"""Smart inventory total_hosts and related computed fields are accurate."""
org = fact_inventory['org']
smart_inv = smart_inventory_factory('computed-fields', 'name=factHostA or name=factHostB', org)
assert smart_inv.total_hosts == 2
assert smart_inv.total_groups == 0
assert smart_inv.total_inventory_sources == 0
assert smart_inv.has_inventory_sources is False
def test_smart_inventory_matches_host_filter(fact_inventory, smart_inventory_factory):
"""Smart inventory hosts should match the equivalent SmartFilter query."""
org = fact_inventory['org']
host_filter = 'groups__name=factGroupA or groups__name=factGroupB'
smart_inv = smart_inventory_factory('match-filter', host_filter, org)
smart_names = sorted(smart_inv.hosts.values_list('name', flat=True))
filter_names = sorted(SmartFilter.query_from_string(host_filter).distinct().values_list('name', flat=True))
assert smart_names == filter_names

View File

@@ -39,7 +39,7 @@ def test_unified_job_detail_exclusive_fields():
For each type, assert that the only fields allowed to be exclusive to
detail view are the allowed types
"""
allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts'))
allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts', 'extra_vars'))
for cls in UnifiedJob.__subclasses__():
list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__))
detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__))

View File

@@ -1,7 +1,6 @@
import copy
import json
import warnings
from unittest.mock import Mock, mock_open, patch
from unittest.mock import Mock, patch
from rest_framework.permissions import IsAuthenticated
@@ -11,7 +10,6 @@ from awx.api.schema import (
AuthenticatedSpectacularSwaggerView,
AuthenticatedSpectacularRedocView,
filter_credential_type_schema,
inject_ai_descriptions,
)
@@ -424,128 +422,3 @@ class TestFilterCredentialTypeSchema:
# PATCH schema: includes None (optional field)
assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None]
class TestInjectAiDescriptions:
"""Unit tests for inject_ai_descriptions postprocessing hook."""
def _make_result(self, operations):
"""Build a minimal OpenAPI result dict from a list of (path, method, operationId, existing_desc) tuples."""
paths = {}
for path, method, op_id, desc in operations:
paths.setdefault(path, {})[method] = {'operationId': op_id}
if desc:
paths[path][method]['x-ai-description'] = desc
return {'paths': paths}
def test_injects_missing_descriptions(self):
"""Test that descriptions are injected for operations without x-ai-description."""
overlay = {'op_list': 'List items', 'op_create': 'Create an item'}
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
('/api/v2/items/', 'post', 'op_create', None),
]
)
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
returned = inject_ai_descriptions(result, None, None, None)
assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'List items'
assert result['paths']['/api/v2/items/']['post']['x-ai-description'] == 'Create an item'
assert returned is result
def test_does_not_overwrite_existing_descriptions(self):
"""Test that existing x-ai-description from decorators is preserved."""
overlay = {'op_list': 'Overlay description'}
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', 'Decorator description'),
]
)
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'Decorator description'
def test_skips_operations_not_in_overlay(self):
"""Test that operations without a matching operationId in the overlay are unchanged."""
overlay = {'op_other': 'Other description'}
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
]
)
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get']
def test_handles_missing_overlay_file(self):
"""Test graceful handling when the overlay file doesn't exist."""
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
]
)
original = copy.deepcopy(result)
with patch('builtins.open', side_effect=FileNotFoundError):
returned = inject_ai_descriptions(result, None, None, None)
assert result == original
assert returned is result
def test_handles_invalid_json(self):
"""Test graceful handling when the overlay file contains invalid JSON."""
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
]
)
original = copy.deepcopy(result)
with patch('builtins.open', mock_open(read_data='not valid json')):
returned = inject_ai_descriptions(result, None, None, None)
assert result == original
assert returned is result
def test_handles_empty_result(self):
"""Test graceful handling when result has no paths."""
result = {}
overlay = {'op_list': 'List items'}
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
returned = inject_ai_descriptions(result, None, None, None)
assert returned is result
def test_skips_non_dict_path_items(self):
"""Test that non-dict values in path items (e.g. parameters list) are skipped."""
overlay = {'op_list': 'List items'}
result = {
'paths': {
'/api/v2/items/': {
'parameters': [{'name': 'id', 'in': 'path'}],
'get': {'operationId': 'op_list'},
}
}
}
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'List items'
def test_handles_operation_without_operation_id(self):
"""Test that operations without operationId are skipped."""
overlay = {'op_list': 'List items'}
result = {'paths': {'/api/v2/items/': {'get': {'summary': 'List'}}}}
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get']

View File

@@ -1,35 +0,0 @@
import pytest
from django.core.management.base import CommandError
from awx.main.management.commands.check_db import Command
def test_check_db_command_success(mocker):
mock_cursor = mocker.MagicMock()
mock_cursor.fetchone.return_value = ['PostgreSQL 12.8 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 9.3.0, 64-bit']
mock_connection = mocker.MagicMock()
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mocker.patch('awx.main.management.commands.check_db.connection', mock_connection)
mocker.patch('awx.main.management.commands.check_db.db_requirement_violations', return_value=None)
command = Command()
result = command.handle()
assert 'Database Version:' in result
mock_cursor.execute.assert_called_once_with('SELECT version()')
def test_check_db_command_version_violations(mocker):
mock_cursor = mocker.MagicMock()
mock_cursor.fetchone.return_value = ['PostgreSQL 11.0 on x86_64-pc-linux-gnu']
mock_connection = mocker.MagicMock()
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
mocker.patch('awx.main.management.commands.check_db.connection', mock_connection)
violation_msg = "At a minimum, postgres version 12 is required, found 11\n"
mocker.patch('awx.main.management.commands.check_db.db_requirement_violations', return_value=violation_msg)
command = Command()
with pytest.raises(CommandError) as exc_info:
command.handle()
assert str(exc_info.value) == violation_msg

View File

@@ -10,8 +10,8 @@ def test_send_messages():
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='')
message = EmailMessage(
@@ -40,8 +40,8 @@ def test_send_messages_with_no_verify_ssl():
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', grafana_no_verify_ssl=True)
message = EmailMessage(
@@ -71,8 +71,8 @@ def test_send_messages_with_dashboardid(dashboardId):
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=dashboardId, panelId='')
message = EmailMessage(
@@ -102,8 +102,8 @@ def test_send_messages_with_panelid(panelId):
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId=panelId)
message = EmailMessage(
@@ -132,8 +132,8 @@ def test_send_messages_with_bothids():
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='42', panelId='42')
message = EmailMessage(
@@ -162,8 +162,8 @@ def test_send_messages_with_emptyids():
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='')
message = EmailMessage(
@@ -192,8 +192,8 @@ def test_send_messages_with_tags():
with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock:
requests_mock.post.return_value.status_code = 200
m = {}
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
m['started'] = dt.datetime.utcfromtimestamp(60).isoformat()
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
m['subject'] = "test subject"
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', annotation_tags=["ansible"])
message = EmailMessage(

View File

@@ -8,7 +8,6 @@ import pytest
import awx
from awx.main.db.profiled_pg.base import RecordedQueryLog
from awx.main.utils.db import db_requirement_violations
QUERY = {'sql': 'SELECT * FROM main_job', 'time': '.01'}
EXPLAIN = 'Seq Scan on public.main_job (cost=0.00..1.18 rows=18 width=86)'
@@ -146,71 +145,3 @@ def test_sql_above_threshold(tmpdir):
assert q['sql'] == QUERY['sql']
assert EXPLAIN in q['explain']
assert 'test_sql_above_threshold' in q['bt']
def test_db_requirement_violations_skip_env_var(mocker):
mocker.patch.dict(os.environ, {'SKIP_PG_VERSION_CHECK': 'true'})
result = db_requirement_violations()
assert result is None
def test_db_requirement_violations_postgresql_sufficient_version(mocker):
mock_connection = mocker.MagicMock()
mock_connection.vendor = 'postgresql'
mock_connection.pg_version = 120000 # Version 12.0
mocker.patch('awx.main.utils.db.connection', mock_connection)
mocker.patch.dict(os.environ, {}, clear=True)
result = db_requirement_violations()
assert result is None
def test_db_requirement_violations_postgresql_insufficient_version(mocker):
mock_connection = mocker.MagicMock()
mock_connection.vendor = 'postgresql'
mock_connection.pg_version = 110000 # Version 11.0
mocker.patch('awx.main.utils.db.connection', mock_connection)
mocker.patch.dict(os.environ, {}, clear=True)
result = db_requirement_violations()
assert result is not None
assert "At a minimum, postgres version 12 is required, found 11" in result
def test_db_requirement_violations_non_postgresql_production(mocker):
mock_connection = mocker.MagicMock()
mock_connection.vendor = 'sqlite'
mocker.patch('awx.main.utils.db.connection', mock_connection)
mocker.patch('awx.main.utils.db.MODE', 'production')
mocker.patch.dict(os.environ, {}, clear=True)
result = db_requirement_violations()
assert result is not None
assert "Running server with 'sqlite' type database is not supported" in result
def test_db_requirement_violations_non_postgresql_development(mocker):
mock_connection = mocker.MagicMock()
mock_connection.vendor = 'sqlite'
mocker.patch('awx.main.utils.db.connection', mock_connection)
mocker.patch('awx.main.utils.db.MODE', 'development')
mocker.patch.dict(os.environ, {}, clear=True)
result = db_requirement_violations()
assert result is None
def test_db_requirement_violations_postgresql_edge_case_version(mocker):
mock_connection = mocker.MagicMock()
mock_connection.vendor = 'postgresql'
mock_connection.pg_version = 129999 # Version 12.9999
mocker.patch('awx.main.utils.db.connection', mock_connection)
mocker.patch.dict(os.environ, {}, clear=True)
result = db_requirement_violations()
assert result is None

View File

@@ -151,6 +151,14 @@ def is_testing(argv=None):
return False
def bypass_in_test(func):
def fn(*args, **kwargs):
if not is_testing():
return func(*args, **kwargs)
return fn
class RequireDebugTrueOrTest(logging.Filter):
"""
Logging filter to output when in DEBUG mode or running tests.

View File

@@ -1,14 +1,9 @@
# Copyright (c) 2017 Ansible by Red Hat
# All Rights Reserved.
from typing import Optional
import os
from awx.settings.application_name import set_application_name
from awx import MODE
from django.conf import settings
from django.db import connection
def set_connection_name(function):
@@ -37,25 +32,3 @@ def bulk_update_sorted_by_id(model, objects, fields, batch_size=1000):
sorted_objects = sorted(objects, key=lambda obj: obj.id)
return model.objects.bulk_update(sorted_objects, fields, batch_size=batch_size)
MIN_PG_VERSION = 12
def db_requirement_violations() -> Optional[str]:
if os.getenv('SKIP_PG_VERSION_CHECK', False):
return None
if connection.vendor == 'postgresql':
# enforce the postgres version is a minimum of 12 (we need this for partitioning); if not, then terminate program with exit code of 1
# In the future if we require a feature of a version of postgres > 12 this should be updated to reflect that.
# The return of connection.pg_version is something like 12013
major_version = connection.pg_version // 10000
if major_version < MIN_PG_VERSION:
return f"At a minimum, postgres version {MIN_PG_VERSION} is required, found {major_version}\n"
return None
else:
if MODE == 'production':
return f"Running server with '{connection.vendor}' type database is not supported\n"
return None

View File

@@ -2,7 +2,14 @@ import re
from functools import reduce
from django.core.exceptions import FieldDoesNotExist
import pyparsing as pp
from pyparsing import (
infixNotation,
opAssoc,
Optional,
Literal,
CharsNotIn,
ParseException,
)
import logging
from logging import Filter
@@ -240,19 +247,32 @@ class SmartFilter(object):
return (assembled_k, assembled_v)
def _extract_key_value(self, t):
k = t[0]
v = t[1] if len(t) > 1 else u""
t_len = len(t)
# Strip quotes from key
if isinstance(k, str) and k.startswith('"') and k.endswith('"'):
k = k[1:-1]
k = None
v = None
# For quoted values, keep the quotes (strip_quotes_* will handle them later).
# For unquoted values, convert to the appropriate Python type.
if isinstance(v, str) and v.startswith('"') and v.endswith('"'):
pass # keep as-is, e.g. '"true"', '""', '"null"'
# key
# "something"=
v_offset = 2
if t_len >= 2 and t[0] == "\"" and t[2] == "\"":
k = t[1]
v_offset = 4
# something=
else:
v = string_to_type(v)
k = t[0]
# value
# ="something"
if t_len > (v_offset + 2) and t[v_offset] == "\"" and t[v_offset + 2] == "\"":
v = u'"' + str(t[v_offset + 1]) + u'"'
# v = t[v_offset + 1]
# empty ""
elif t_len > (v_offset + 1):
v = u""
# no ""
else:
v = string_to_type(t[v_offset])
return (k, v)
@@ -268,7 +288,7 @@ class SmartFilter(object):
try:
model = get_model(relation)
except LookupError:
raise pp.ParseException('No related field named %s' % relation)
raise ParseException('No related field named %s' % relation)
search_kwargs = {}
if model is not None:
@@ -308,31 +328,34 @@ class SmartFilter(object):
def query_from_string(cls, filter_string):
"""
TODO:
* handle values with " via: a.b.c.d="hello\"world"
* handle keys with " via: a.\"b.c="yeah"
* handle key with __ in it
"""
filter_string_raw = filter_string
filter_string = str(filter_string)
unquoted = pp.CharsNotIn('()= \t\r\n"')
unquoted.skipWhitespace = True
quoted = pp.QuotedString('"', esc_char='\\', unquote_results=False)
token = quoted | unquoted
unicode_spaces = list(set(str(c) for c in filter_string if c.isspace()))
unicode_spaces_other = unicode_spaces + [u'(', u')', u'=', u'"']
atom = CharsNotIn(unicode_spaces_other)
atom_inside_quotes = CharsNotIn(u'"')
atom_quoted = Literal('"') + Optional(atom_inside_quotes) + Literal('"')
EQUAL = Literal('=')
operand = token + pp.Suppress("=") + pp.Optional(token, default="")
operand.set_parse_action(cls.BoolOperand)
grammar = (atom_quoted | atom) + EQUAL + Optional((atom_quoted | atom))
grammar.setParseAction(cls.BoolOperand)
bool_expr = pp.infix_notation(
operand,
boolExpr = infixNotation(
grammar,
[
(pp.Keyword("and"), 2, pp.OpAssoc.LEFT, cls.BoolAnd),
(pp.Keyword("or"), 2, pp.OpAssoc.LEFT, cls.BoolOr),
("and", 2, opAssoc.LEFT, cls.BoolAnd),
("or", 2, opAssoc.LEFT, cls.BoolOr),
],
)
try:
res = bool_expr.parse_string(filter_string, parse_all=True)
except (pp.ParseException, FieldDoesNotExist):
res = boolExpr.parseString('(' + filter_string + ')')
except (ParseException, FieldDoesNotExist):
raise RuntimeError(u"Invalid query %s" % filter_string_raw)
if len(res) > 0:

View File

@@ -1,64 +0,0 @@
class LazyLoadDict(dict):
"""A dict subclass that calls a loader function on first read access.
Writes (e.g. during the loading process itself) go straight through
without triggering the loader.
"""
def __init__(self, loader):
super().__init__()
self._loader = loader
self._loaded = False
def _ensure_loaded(self):
if not self._loaded:
self._loaded = True
self._loader()
def __getitem__(self, key):
self._ensure_loaded()
return super().__getitem__(key)
def get(self, key, default=None):
self._ensure_loaded()
return super().get(key, default)
def __contains__(self, key):
self._ensure_loaded()
return super().__contains__(key)
def __iter__(self):
self._ensure_loaded()
return super().__iter__()
def __len__(self):
self._ensure_loaded()
return super().__len__()
def keys(self):
self._ensure_loaded()
return super().keys()
def values(self):
self._ensure_loaded()
return super().values()
def items(self):
self._ensure_loaded()
return super().items()
def __bool__(self):
self._ensure_loaded()
return super().__bool__()
def __repr__(self):
self._ensure_loaded()
return super().__repr__()
def copy(self):
self._ensure_loaded()
return super().copy()
def clear(self):
super().clear()
self._loaded = True

View File

@@ -215,9 +215,6 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000
# events into the database
JOB_EVENT_WORKERS = 4
# Minimum number of workers for the dispatcher (dispatcherd) process pool
DISPATCHER_MIN_WORKERS = 4
# The number of seconds to buffer callback receiver bulk
# writes in memory before flushing via JobEvent.objects.bulk_create()
JOB_EVENT_BUFFER_SECONDS = 1
@@ -448,7 +445,7 @@ DISPATCHER_SCHEDULE = {
# Django Caching Configuration
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
CACHES = {'default': {'BACKEND': 'ansible_base.lib.cache.redis_cache.DABRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}}
CACHES = {'default': {'BACKEND': 'awx.main.cache.AWXRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}}
ROLE_SINGLETON_USER_RELATIONSHIP = ''
ROLE_SINGLETON_TEAM_RELATIONSHIP = ''
@@ -1038,11 +1035,8 @@ SPECTACULAR_SETTINGS = {
# Use our custom schema class that handles swagger_topic and deprecated views
'DEFAULT_SCHEMA_CLASS': 'awx.api.schema.CustomAutoSchema',
'COMPONENT_SPLIT_REQUEST': True,
# Postprocessing hooks for OpenAPI schema generation
'POSTPROCESSING_HOOKS': [
'awx.api.schema.filter_credential_type_schema',
'awx.api.schema.inject_ai_descriptions',
],
# Postprocessing hook to filter CredentialType enum values
'POSTPROCESSING_HOOKS': ['awx.api.schema.filter_credential_type_schema'],
'SWAGGER_UI_SETTINGS': {
'deepLinking': True,
'persistAuthorization': True,

View File

@@ -34,15 +34,13 @@ options:
aliases: [ tower_password , aap_password ]
aap_token:
description:
- The OAuth token to use, sent as a Bearer token in the Authorization header.
- When connecting through the AAP gateway, use a token issued by the gateway.
- The OAuth token to use.
- This value can be in one of two formats.
- A string which is the token itself. (i.e. bqV5txm97wqJqtkxlMkhQz0pKhRMMX)
- A dictionary structure as set as a fact by the M(ansible.platform.token) module.
- A dictionary structure as returned by the token module.
- If value not set, will try environment variable C(CONTROLLER_OAUTH_TOKEN) and then config files
type: raw
version_added: "3.7.0"
aliases: [ oauth_token, controller_oauthtoken, tower_oauthtoken ]
validate_certs:
description:
- Whether to allow insecure connections to AWX.

View File

@@ -42,23 +42,13 @@ options:
alternatives: 'TOWER_PASSWORD, AAP_PASSWORD'
aap_token:
description:
- The OAuth token to use, sent as a Bearer token in the Authorization header.
- When connecting through the AAP gateway, use a token issued by the gateway.
- The OAuth token to use.
env:
- name: CONTROLLER_OAUTH_TOKEN
deprecated:
collection_name: 'awx.awx'
version: '4.0.0'
why: Collection name change
alternatives: 'AAP_TOKEN'
- name: TOWER_OAUTH_TOKEN
deprecated:
collection_name: 'awx.awx'
version: '4.0.0'
why: Collection name change
alternatives: 'AAP_TOKEN'
- name: AAP_TOKEN
aliases: [ oauth_token, controller_oauthtoken, tower_oauthtoken ]
deprecated:
collection_name: 'awx.awx'
version: '4.0.0'
why: Collection name change
verify_ssl:
description:
- Specify whether Ansible should verify the SSL certificate of the controller host.

View File

@@ -34,10 +34,7 @@ class ControllerAWXKitModule(ControllerModule):
def authenticate(self):
try:
if self.aap_token:
self.connection.session.headers['Authorization'] = 'Bearer {0}'.format(self.aap_token)
else:
self.connection.login(username=self.username, password=self.password)
self.connection.login(username=self.username, password=self.password)
self.authenticated = True
except Exception:
self.fail_json("Failed to authenticate")

View File

@@ -99,7 +99,6 @@ class ControllerModule(AnsibleModule):
aap_token=dict(
type='raw',
no_log=True,
aliases=['oauth_token', 'controller_oauthtoken', 'tower_oauthtoken'],
required=False,
fallback=(env_fallback, ['CONTROLLER_OAUTH_TOKEN', 'TOWER_OAUTH_TOKEN', 'AAP_TOKEN'])
),
@@ -119,12 +118,10 @@ class ControllerModule(AnsibleModule):
'request_timeout': 'request_timeout',
'max_retries': 'max_retries',
'retry_backoff_factor': 'retry_backoff_factor',
'aap_token': 'aap_token',
}
host = '127.0.0.1'
username = None
password = None
aap_token = None
verify_ssl = True
request_timeout = 10
max_retries = 5
@@ -163,8 +160,6 @@ class ControllerModule(AnsibleModule):
if direct_value is not None:
setattr(self, short_param, direct_value)
self._parse_aap_token()
# Perform some basic validation
if not self.host.startswith(("https://", "http://")): # NOSONAR
self.host = "https://{0}".format(self.host)
@@ -191,15 +186,6 @@ class ControllerModule(AnsibleModule):
except Exception as e:
self.fail_json(msg="Unable to resolve controller_host ({1}): {0}".format(self.url.hostname, e))
def _parse_aap_token(self):
# aap_token can be the token string itself, or the dict that the
# ansible.platform.token module sets as the aap_token fact
if isinstance(self.aap_token, dict):
if 'token' in self.aap_token:
self.aap_token = self.aap_token['token']
else:
self.fail_json(msg="The provided dict in aap_token did not properly contain the token entry")
def build_url(self, endpoint, query_params=None, app_key=None):
# Make sure we start with /api/vX
if not endpoint.startswith("/"):
@@ -298,8 +284,7 @@ class ControllerModule(AnsibleModule):
# If we made it here then we have values from reading the ini file, so let's pull them out into a dict
config_data = {}
# 'oauth_token' is the legacy (pre-aap_token) config file key, kept for backward compatibility
for honorred_setting in list(self.short_params) + ['oauth_token']:
for honorred_setting in self.short_params:
try:
config_data[honorred_setting] = config.get('general', honorred_setting)
except NoOptionError:
@@ -311,12 +296,6 @@ class ControllerModule(AnsibleModule):
except Exception as e:
raise_from(ConfigFileException("An unknown exception occured trying to load config file: {0}".format(e)), e)
# Backward compatibility: config files written for older collection
# releases used the oauth_token key; map it to aap_token.
# If both keys are present, the new aap_token key wins.
if 'oauth_token' in config_data and 'aap_token' not in config_data:
config_data['aap_token'] = config_data['oauth_token']
# If we made it here, we have a dict which has values in it from our config, any final settings logic can be performed here
for honorred_setting in self.short_params:
if honorred_setting in config_data:
@@ -593,7 +572,12 @@ class ControllerAPIModule(ControllerModule):
# Extract the headers, this will be used in a couple of places
headers = kwargs.get('headers', {})
headers['Authorization'] = self._get_authorization_header(**kwargs)
# Authenticate to AWX (if not already done so)
if not self.authenticated:
# This method will set a cookie in the cookie jar for us
self.authenticate(**kwargs)
headers['Authorization'] = self._get_basic_authorization_header()
if method in ['POST', 'PUT', 'PATCH']:
headers.setdefault('Content-Type', 'application/json')
@@ -777,19 +761,6 @@ class ControllerAPIModule(ControllerModule):
return prefix
def _get_authorization_header(self, **kwargs):
if self.aap_token:
# A token (e.g. issued by the AAP gateway) is validated by the server on
# every request, so no login round-trip is needed
return 'Bearer {0}'.format(self.aap_token)
# Authenticate to AWX (if not already done so)
if not self.authenticated:
# This method will set a cookie in the cookie jar for us
self.authenticate(**kwargs)
return self._get_basic_authorization_header()
def _get_basic_authorization_header(self):
basic_credentials = b64encode("{0}:{1}".format(self.username, self.password).encode()).decode()
return "Basic {0}".format(basic_credentials)

View File

@@ -1,138 +0,0 @@
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import json
import pytest
from ansible.module_utils import basic
from ansible.module_utils.common.text.converters import to_bytes
from requests.models import Response
from unittest import mock
def getheader(self, header_name, default):
return default
def read(self):
return json.dumps({})
def status(self):
return 200
def make_recorder():
"""Build a mock for Request.open that records every call made through it."""
calls = []
def opener(self, method, url, **kwargs):
calls.append({'method': method, 'url': url, 'headers': kwargs.get('headers') or {}})
r = Response()
r.getheader = getheader.__get__(r)
r.read = read.__get__(r)
r.status = status.__get__(r)
return r
return opener, calls
def make_module(collection_import, module_args, **kwargs):
ControllerAPIModule = collection_import('plugins.module_utils.controller_api').ControllerAPIModule
cli_data = {'ANSIBLE_MODULE_ARGS': module_args}
# patch the cached args directly: AnsibleModule caches sys.argv parsing in
# basic._ANSIBLE_ARGS, so patching sys.argv would leak args between tests
with mock.patch.object(basic, '_ANSIBLE_ARGS', to_bytes(json.dumps(cli_data))):
# ansible-core 2.21+ also requires a serialization profile alongside the args
if hasattr(basic, '_ANSIBLE_PROFILE'):
with mock.patch.object(basic, '_ANSIBLE_PROFILE', 'legacy'):
return ControllerAPIModule(argument_spec=dict(), **kwargs)
return ControllerAPIModule(argument_spec=dict(), **kwargs)
@pytest.mark.parametrize(
'token_value',
[
'a-token-string',
{'token': 'a-token-string', 'id': 1}, # the aap_token fact set by ansible.platform.token
],
ids=['string', 'dict'],
)
def test_aap_token_sends_bearer_header(collection_import, token_value):
module = make_module(collection_import, {'aap_token': token_value})
assert module.aap_token == 'a-token-string'
opener, calls = make_recorder()
with mock.patch('ansible.module_utils.urls.Request.open', new=opener):
module.get_endpoint('ping')
assert len(calls) == 1, calls
assert calls[0]['headers']['Authorization'] == 'Bearer a-token-string'
# a token needs no login round-trip
assert module.authenticated is False
@pytest.mark.parametrize('param', ['oauth_token', 'controller_oauthtoken', 'tower_oauthtoken'])
def test_aap_token_legacy_aliases(collection_import, param):
module = make_module(collection_import, {param: 'legacy-token'})
assert module.aap_token == 'legacy-token'
def test_lookup_oauth_token_option_maps_to_aap_token(collection_import):
# Older lookup/inventory plugin releases pass options through as direct
# params keyed by the plugin option name; oauth_token must resolve to
# aap_token via the argspec alias.
module = make_module(collection_import, {'oauth_token': 'plugin-token'})
assert module.aap_token == 'plugin-token'
opener, calls = make_recorder()
with mock.patch('ansible.module_utils.urls.Request.open', new=opener):
module.get_endpoint('ping')
assert calls[0]['headers']['Authorization'] == 'Bearer plugin-token'
def test_config_file_legacy_oauth_token_key(collection_import, tmp_path):
# tower_cli.cfg-style config files from older releases used the oauth_token key
config_file = tmp_path / 'tower_cli.cfg'
config_file.write_text('[general]\nhost = https://127.0.0.1\noauth_token = ini-legacy-token\n')
module = make_module(collection_import, {'controller_config_file': str(config_file)})
assert module.aap_token == 'ini-legacy-token'
def test_config_file_aap_token_wins_over_legacy_key(collection_import, tmp_path):
config_file = tmp_path / 'tower_cli.cfg'
config_file.write_text('[general]\nhost = https://127.0.0.1\noauth_token = ini-legacy-token\naap_token = ini-new-token\n')
module = make_module(collection_import, {'controller_config_file': str(config_file)})
assert module.aap_token == 'ini-new-token'
def test_aap_token_dict_without_token_entry_fails(collection_import):
errors = []
def error_callback(**kwargs):
errors.append(kwargs)
raise SystemExit(1)
with pytest.raises(SystemExit):
make_module(collection_import, {'aap_token': {'id': 1}}, error_callback=error_callback)
assert 'did not properly contain the token entry' in errors[0]['msg']
def test_no_token_falls_back_to_basic_auth(collection_import):
module = make_module(collection_import, {'controller_username': 'admin', 'controller_password': 'secret'})
opener, calls = make_recorder()
with mock.patch('ansible.module_utils.urls.Request.open', new=opener):
module.get_endpoint('ping')
# first call is the authentication probe, second is the actual request
assert len(calls) == 2, calls
for call in calls:
assert call['headers']['Authorization'].startswith('Basic '), call
assert module.authenticated is True

View File

@@ -15,6 +15,19 @@ markers =
filterwarnings =
error
# FIXME: Upgrade python-dateutil https://github.com/dateutil/dateutil/issues/1340
once:datetime.datetime.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC:DeprecationWarning
# NOTE: the following are present using python 3.11
# FIXME: Delete this entry once `pyparsing` is updated.
once:module 'sre_constants' is deprecated:DeprecationWarning:_pytest.assertion.rewrite
# FIXME: Delete this entry once `polymorphic` is updated.
once:pkg_resources is deprecated as an API.
# FIXME: Delete this entry once `zope` is updated.
once:Deprecated call to `pkg_resources.declare_namespace.'zope'.`.\nImplementing implicit namespace packages .as specified in PEP 420. is preferred to `pkg_resources.declare_namespace`. See https.//setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages:DeprecationWarning:
# FIXME: Delete this entry once the deprecation is acted upon.
# Note: RemovedInDjango51Warning may not exist in newer Django versions
ignore:'index_together' is deprecated. Use 'Meta.indexes' in 'main.\w+' instead.

View File

@@ -1,15 +1,11 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["tekton"],
"tekton": {
"schedule": ["0 * * * *"],
"packageRules": [
{
"description": "Update aap-ci tekton-catalog pipeline bundles",
"matchPackageNames": ["/^quay\\.io\\/aap-ci\\/tekton-catalog\\/pipeline\\/"],
"matchManagers": ["tekton"],
"automerge": true
}
]
}
}
"packageRules": [
{
"description": "Update aap-ci tekton-catalog pipeline bundles",
"matchPackageNames": ["/^quay\\.io\\/aap-ci\\/tekton-catalog\\/pipeline\\//"],
"matchManagers": ["tekton"],
"automerge": true
}
]
}

View File

@@ -36,7 +36,7 @@ maturin # pydantic-core build dep
msgpack
msrestazure
OPA-python-client==2.0.2 # upgrading requires urllib3 2.5.0+ which is blocked by other deps
kubernetes>=36.0.0 # fixes NO_PROXY silently being reset to None
kubernetes>=35.0.0
openshift
opentelemetry-api~=1.37 # new y streams can be drastically different, in a good way
opentelemetry-sdk~=1.37
@@ -50,7 +50,7 @@ pyasn1>=0.6.2 # CVE-2026-2349
pygerduty
PyGithub
pyopenssl
pyparsing>3.0 # Upgraded to v3 and changed import patterns
pyparsing==2.4.7 # Upgrading to v3 of pyparsing introduce errors on smart host filtering: Expected 'or' term, found 'or' (at char 15), (line:1, col:16)
python-daemon
python-dsv-sdk>=1.0.4
python-tss-sdk>=1.2.1

View File

@@ -10,7 +10,6 @@ aiohttp[speedups]==3.13.0
# via
# -r /awx_devel/requirements/requirements.in
# aiohttp-retry
# kubernetes
# opa-python-client
# twilio
aiohttp-retry==2.9.1
@@ -252,7 +251,7 @@ jsonschema==4.25.1
# drf-spectacular
jsonschema-specifications==2025.9.1
# via jsonschema
kubernetes==36.0.0
kubernetes==35.0.0
# via
# -r /awx_devel/requirements/requirements.in
# openshift
@@ -393,7 +392,7 @@ pyopenssl==25.3.0
# via
# -r /awx_devel/requirements/requirements.in
# twisted
pyparsing==3.3.2
pyparsing==2.4.7
# via -r /awx_devel/requirements/requirements.in
python-daemon==3.1.2
# via