mirror of
https://github.com/ansible/awx.git
synced 2026-06-16 04:07:41 -02:30
Compare commits
20 Commits
AAP-60052
...
fix/openap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366a105f7e | ||
|
|
849f5f796c | ||
|
|
c8981e321e | ||
|
|
d5e5ea3670 | ||
|
|
d566f71ae0 | ||
|
|
c8cb465fde | ||
|
|
49e21d7c1c | ||
|
|
b531151931 | ||
|
|
54857c7a82 | ||
|
|
e03899b581 | ||
|
|
b4f27de4a2 | ||
|
|
5cc467d4cf | ||
|
|
b14b9e1771 | ||
|
|
c4c2779976 | ||
|
|
4bdb11c2a6 | ||
|
|
80f8ee1dec | ||
|
|
f22df56e44 | ||
|
|
fccb6744f9 | ||
|
|
200a68aefa | ||
|
|
9b922f70ed |
55
.github/workflows/_repo-owns-branch.yml
vendored
55
.github/workflows/_repo-owns-branch.yml
vendored
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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"
|
||||
9
.github/workflows/devel_images.yml
vendored
9
.github/workflows/devel_images.yml
vendored
@@ -12,12 +12,11 @@ on:
|
||||
- feature_*
|
||||
- stable-*
|
||||
jobs:
|
||||
check-ownership:
|
||||
uses: ./.github/workflows/_repo-owns-branch.yml
|
||||
|
||||
push-development-images:
|
||||
needs: check-ownership
|
||||
if: needs.check-ownership.outputs.should_run == 'true'
|
||||
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_')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
|
||||
9
.github/workflows/spec-sync-on-merge.yml
vendored
9
.github/workflows/spec-sync-on-merge.yml
vendored
@@ -20,12 +20,11 @@ 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:
|
||||
needs: check-ownership
|
||||
if: needs.check-ownership.outputs.should_run == 'true'
|
||||
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_')))
|
||||
name: Sync OpenAPI spec to central repo
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
9
.github/workflows/upload_schema.yml
vendored
9
.github/workflows/upload_schema.yml
vendored
@@ -13,12 +13,11 @@ on:
|
||||
- feature_**
|
||||
- stable-**
|
||||
jobs:
|
||||
check-ownership:
|
||||
uses: ./.github/workflows/_repo-owns-branch.yml
|
||||
|
||||
push:
|
||||
needs: check-ownership
|
||||
if: needs.check-ownership.outputs.should_run == 'true'
|
||||
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_')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
|
||||
@@ -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:54d9e941748bae94b2154b3b253a985e628751dfa4508a138d9b05f74a3c1ddf
|
||||
value: quay.io/aap-ci/tekton-catalog/pipeline/test/aap-api-tests:0.1@sha256:50aadd6725a239ab53247deb7cf601d1163ceb1792792fd239a3f37d21a490d7
|
||||
- name: kind
|
||||
value: pipeline
|
||||
- name: secret
|
||||
|
||||
@@ -52,14 +52,6 @@ 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')
|
||||
@@ -79,14 +71,6 @@ 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
|
||||
|
||||
471
awx/api/openapi_ai_descriptions.json
Normal file
471
awx/api/openapi_ai_descriptions.json
Normal file
@@ -0,0 +1,471 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -53,6 +55,37 @@ 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."""
|
||||
|
||||
|
||||
@@ -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.signals import update_inventory_computed_fields
|
||||
from awx.main.tasks.system import update_inventory_computed_fields
|
||||
|
||||
from awx.main.validators import vars_validate_or_raise
|
||||
|
||||
@@ -5450,7 +5450,11 @@ 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))
|
||||
elif 'secondly' in a_rule.lower():
|
||||
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():
|
||||
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))
|
||||
|
||||
@@ -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, set_environ
|
||||
from awx.main.utils import get_awx_version
|
||||
from awx.main.utils.analytics_proxy import OIDCClient
|
||||
from rest_framework import status
|
||||
|
||||
@@ -210,32 +210,31 @@ 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
|
||||
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
|
||||
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
|
||||
#
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
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 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.conf import register, fields
|
||||
from django.core.management.base import CommandError
|
||||
from django.db.models.signals import pre_migrate
|
||||
|
||||
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
@@ -43,42 +46,6 @@ 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
|
||||
|
||||
@@ -100,13 +67,5 @@ 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()
|
||||
self.load_inventory_plugins()
|
||||
pre_migrate.connect(self.check_db_requirement, sender=self)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
@@ -25,7 +25,7 @@ def get_dispatcherd_config(for_service: bool = False, mock_publish: bool = False
|
||||
"version": 2,
|
||||
"service": {
|
||||
"pool_kwargs": {
|
||||
"min_workers": settings.JOB_EVENT_WORKERS,
|
||||
"min_workers": settings.DISPATCHER_MIN_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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Copyright (c) 2015 Ansible, Inc.
|
||||
# All Rights Reserved
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
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"""
|
||||
@@ -13,4 +15,8 @@ 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)
|
||||
|
||||
@@ -52,7 +52,11 @@ 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
|
||||
credential_type=ssh_type,
|
||||
name='Demo Credential',
|
||||
inputs={'username': getattr(superuser, 'username', 'null')},
|
||||
created_by=superuser,
|
||||
organization=o,
|
||||
)
|
||||
|
||||
if superuser:
|
||||
|
||||
@@ -211,7 +211,7 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
|
||||
return AdHocCommand.objects.create(**data)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
|
||||
def add_to_update_fields(name):
|
||||
if name not in update_fields:
|
||||
|
||||
@@ -177,7 +177,7 @@ class CreatedModifiedModel(BaseModel):
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = list(kwargs.get('update_fields', []))
|
||||
update_fields = list(kwargs.get('update_fields') or [])
|
||||
# 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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
# 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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
user = get_current_user()
|
||||
if user and not user.id:
|
||||
user = None
|
||||
|
||||
@@ -47,6 +47,7 @@ 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']
|
||||
@@ -569,7 +570,7 @@ class CredentialTypeHelper:
|
||||
|
||||
|
||||
class ManagedCredentialType(SimpleNamespace):
|
||||
registry = {}
|
||||
registry = None # initialized as LazyLoadDict after load_credentials is defined
|
||||
|
||||
|
||||
class CredentialInputSource(PrimordialModel):
|
||||
@@ -661,6 +662,8 @@ 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}
|
||||
@@ -692,3 +695,8 @@ 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)
|
||||
|
||||
@@ -27,7 +27,10 @@ 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,
|
||||
@@ -926,12 +929,22 @@ 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 = dict()
|
||||
injectors = LazyLoadDict(_load_inventory_plugins)
|
||||
|
||||
# From the options of the Django management base command
|
||||
INVENTORY_UPDATE_VERBOSITY_CHOICES = [
|
||||
@@ -1149,7 +1162,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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
is_new_instance = not bool(self.pk)
|
||||
|
||||
# Set name automatically. Include PK (or placeholder) to make sure the names are always unique.
|
||||
|
||||
@@ -347,7 +347,7 @@ class JobTemplate(
|
||||
return actual_slice_count
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
# 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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
self.failed = bool(self.dark or self.failures)
|
||||
update_fields.append('failed')
|
||||
super(JobHostSummary, self).save(*args, **kwargs)
|
||||
|
||||
@@ -99,7 +99,7 @@ class NotificationTemplate(CommonModelNameNotUnique):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
new_instance = not bool(self.pk)
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
|
||||
# preserve existing notification messages if not overwritten by new messages
|
||||
if not new_instance:
|
||||
|
||||
@@ -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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
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('_'):
|
||||
|
||||
@@ -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)
|
||||
return getattr(object_role.content_object, role_name, None)
|
||||
|
||||
|
||||
def give_or_remove_permission(role, actor, giving=True, rd=None):
|
||||
@@ -649,6 +649,8 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
# 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', [])
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
|
||||
# Get status before save...
|
||||
status_before = self.status or 'new'
|
||||
|
||||
@@ -900,7 +900,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
||||
return 'workflow_approval_template'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = list(kwargs.get('update_fields', []))
|
||||
update_fields = list(kwargs.get('update_fields') or [])
|
||||
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()
|
||||
|
||||
@@ -688,6 +688,17 @@ 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,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -68,10 +69,12 @@ 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')
|
||||
@@ -83,6 +86,16 @@ 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.
|
||||
@@ -98,6 +111,11 @@ 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:
|
||||
@@ -240,12 +258,17 @@ 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
|
||||
@@ -326,24 +349,22 @@ def apply_cluster_membership_policies():
|
||||
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
|
||||
|
||||
|
||||
@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 _resolve_setting_dependents(key):
|
||||
return settings_registry.get_dependent_settings(key)
|
||||
|
||||
if 'LOG_AGGREGATOR_LEVEL' in setting_keys:
|
||||
|
||||
def _post_setting_invalidation(invalidated_keys):
|
||||
if 'LOG_AGGREGATOR_LEVEL' in invalidated_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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
from unittest import mock
|
||||
@@ -258,92 +257,3 @@ 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
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# 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
|
||||
|
||||
@@ -20,6 +18,48 @@ 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):
|
||||
@@ -50,3 +90,184 @@ 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
|
||||
|
||||
@@ -139,6 +139,7 @@ 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"),
|
||||
@@ -202,6 +203,7 @@ 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'",
|
||||
]
|
||||
|
||||
191
awx/main/tests/functional/api/test_smart_inventory.py
Normal file
191
awx/main/tests/functional/api/test_smart_inventory.py
Normal file
@@ -0,0 +1,191 @@
|
||||
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,
|
||||
)
|
||||
240
awx/main/tests/functional/dab_rbac/test_notification_rbac.py
Normal file
240
awx/main/tests/functional/dab_rbac/test_notification_rbac.py
Normal file
@@ -0,0 +1,240 @@
|
||||
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'])
|
||||
@@ -173,6 +173,22 @@ 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"""
|
||||
|
||||
@@ -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.JOB_EVENT_WORKERS = 1
|
||||
settings.DISPATCHER_MIN_WORKERS = 1
|
||||
settings.DISPATCHER_SCHEDULE = {}
|
||||
|
||||
config_one = get_dispatcherd_config(for_service=True)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
|
||||
# AWX context managers for testing
|
||||
from awx.main.signals import disable_activity_stream, disable_computed_fields, update_inventory_computed_fields
|
||||
from awx.main.signals import disable_activity_stream, disable_computed_fields
|
||||
from awx.main.tasks.system import update_inventory_computed_fields
|
||||
|
||||
# AWX models
|
||||
from awx.main.models.organization import Organization
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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 (
|
||||
@@ -128,3 +134,94 @@ 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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
@@ -9,18 +12,38 @@ def mock_setup_tower_managed_defaults(mocker):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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)
|
||||
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)
|
||||
|
||||
apps.get_app_config('main')._load_credential_types_feature()
|
||||
_sync_credential_types_to_db()
|
||||
|
||||
mock_setup_tower_managed_defaults.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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)
|
||||
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)
|
||||
|
||||
apps.get_app_config('main')._load_credential_types_feature()
|
||||
_sync_credential_types_to_db()
|
||||
|
||||
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
|
||||
|
||||
@@ -160,3 +160,38 @@ 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'
|
||||
|
||||
@@ -287,6 +287,20 @@ 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)
|
||||
|
||||
320
awx/main/tests/live/tests/test_smart_inventory.py
Normal file
320
awx/main/tests/live/tests/test_smart_inventory.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""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
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy
|
||||
import json
|
||||
import warnings
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock, mock_open, patch
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
@@ -10,6 +11,7 @@ from awx.api.schema import (
|
||||
AuthenticatedSpectacularSwaggerView,
|
||||
AuthenticatedSpectacularRedocView,
|
||||
filter_credential_type_schema,
|
||||
inject_ai_descriptions,
|
||||
)
|
||||
|
||||
|
||||
@@ -422,3 +424,128 @@ 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']
|
||||
|
||||
35
awx/main/tests/unit/management/commands/test_check_db.py
Normal file
35
awx/main/tests/unit/management/commands/test_check_db.py
Normal file
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).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.utcfromtimestamp(60).isoformat()
|
||||
m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat()
|
||||
m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat()
|
||||
m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat()
|
||||
m['subject'] = "test subject"
|
||||
backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', annotation_tags=["ansible"])
|
||||
message = EmailMessage(
|
||||
|
||||
@@ -8,6 +8,7 @@ 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)'
|
||||
@@ -145,3 +146,71 @@ 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
|
||||
|
||||
@@ -151,14 +151,6 @@ 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.
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# 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):
|
||||
@@ -32,3 +37,25 @@ 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
|
||||
|
||||
@@ -2,14 +2,7 @@ import re
|
||||
from functools import reduce
|
||||
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from pyparsing import (
|
||||
infixNotation,
|
||||
opAssoc,
|
||||
Optional,
|
||||
Literal,
|
||||
CharsNotIn,
|
||||
ParseException,
|
||||
)
|
||||
import pyparsing as pp
|
||||
import logging
|
||||
from logging import Filter
|
||||
|
||||
@@ -247,32 +240,19 @@ class SmartFilter(object):
|
||||
return (assembled_k, assembled_v)
|
||||
|
||||
def _extract_key_value(self, t):
|
||||
t_len = len(t)
|
||||
k = t[0]
|
||||
v = t[1] if len(t) > 1 else u""
|
||||
|
||||
k = None
|
||||
v = None
|
||||
# Strip quotes from key
|
||||
if isinstance(k, str) and k.startswith('"') and k.endswith('"'):
|
||||
k = k[1:-1]
|
||||
|
||||
# key
|
||||
# "something"=
|
||||
v_offset = 2
|
||||
if t_len >= 2 and t[0] == "\"" and t[2] == "\"":
|
||||
k = t[1]
|
||||
v_offset = 4
|
||||
# something=
|
||||
# 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"'
|
||||
else:
|
||||
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])
|
||||
v = string_to_type(v)
|
||||
|
||||
return (k, v)
|
||||
|
||||
@@ -288,7 +268,7 @@ class SmartFilter(object):
|
||||
try:
|
||||
model = get_model(relation)
|
||||
except LookupError:
|
||||
raise ParseException('No related field named %s' % relation)
|
||||
raise pp.ParseException('No related field named %s' % relation)
|
||||
|
||||
search_kwargs = {}
|
||||
if model is not None:
|
||||
@@ -328,34 +308,31 @@ 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)
|
||||
|
||||
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('=')
|
||||
unquoted = pp.CharsNotIn('()= \t\r\n"')
|
||||
unquoted.skipWhitespace = True
|
||||
quoted = pp.QuotedString('"', esc_char='\\', unquote_results=False)
|
||||
token = quoted | unquoted
|
||||
|
||||
grammar = (atom_quoted | atom) + EQUAL + Optional((atom_quoted | atom))
|
||||
grammar.setParseAction(cls.BoolOperand)
|
||||
operand = token + pp.Suppress("=") + pp.Optional(token, default="")
|
||||
operand.set_parse_action(cls.BoolOperand)
|
||||
|
||||
boolExpr = infixNotation(
|
||||
grammar,
|
||||
bool_expr = pp.infix_notation(
|
||||
operand,
|
||||
[
|
||||
("and", 2, opAssoc.LEFT, cls.BoolAnd),
|
||||
("or", 2, opAssoc.LEFT, cls.BoolOr),
|
||||
(pp.Keyword("and"), 2, pp.OpAssoc.LEFT, cls.BoolAnd),
|
||||
(pp.Keyword("or"), 2, pp.OpAssoc.LEFT, cls.BoolOr),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
res = boolExpr.parseString('(' + filter_string + ')')
|
||||
except (ParseException, FieldDoesNotExist):
|
||||
res = bool_expr.parse_string(filter_string, parse_all=True)
|
||||
except (pp.ParseException, FieldDoesNotExist):
|
||||
raise RuntimeError(u"Invalid query %s" % filter_string_raw)
|
||||
|
||||
if len(res) > 0:
|
||||
|
||||
64
awx/main/utils/lazy_registry.py
Normal file
64
awx/main/utils/lazy_registry.py
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
@@ -215,6 +215,9 @@ 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
|
||||
@@ -445,7 +448,7 @@ DISPATCHER_SCHEDULE = {
|
||||
|
||||
# Django Caching Configuration
|
||||
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
|
||||
CACHES = {'default': {'BACKEND': 'awx.main.cache.AWXRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}}
|
||||
CACHES = {'default': {'BACKEND': 'ansible_base.lib.cache.redis_cache.DABRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}}
|
||||
|
||||
ROLE_SINGLETON_USER_RELATIONSHIP = ''
|
||||
ROLE_SINGLETON_TEAM_RELATIONSHIP = ''
|
||||
@@ -1035,8 +1038,11 @@ 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 hook to filter CredentialType enum values
|
||||
'POSTPROCESSING_HOOKS': ['awx.api.schema.filter_credential_type_schema'],
|
||||
# Postprocessing hooks for OpenAPI schema generation
|
||||
'POSTPROCESSING_HOOKS': [
|
||||
'awx.api.schema.filter_credential_type_schema',
|
||||
'awx.api.schema.inject_ai_descriptions',
|
||||
],
|
||||
'SWAGGER_UI_SETTINGS': {
|
||||
'deepLinking': True,
|
||||
'persistAuthorization': True,
|
||||
|
||||
@@ -34,13 +34,15 @@ options:
|
||||
aliases: [ tower_password , aap_password ]
|
||||
aap_token:
|
||||
description:
|
||||
- The OAuth token to use.
|
||||
- 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.
|
||||
- This value can be in one of two formats.
|
||||
- A string which is the token itself. (i.e. bqV5txm97wqJqtkxlMkhQz0pKhRMMX)
|
||||
- A dictionary structure as returned by the token module.
|
||||
- A dictionary structure as set as a fact by the M(ansible.platform.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.
|
||||
|
||||
@@ -42,13 +42,23 @@ options:
|
||||
alternatives: 'TOWER_PASSWORD, AAP_PASSWORD'
|
||||
aap_token:
|
||||
description:
|
||||
- The OAuth token to use.
|
||||
- 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.
|
||||
env:
|
||||
- name: AAP_TOKEN
|
||||
- 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 ]
|
||||
verify_ssl:
|
||||
description:
|
||||
- Specify whether Ansible should verify the SSL certificate of the controller host.
|
||||
|
||||
@@ -34,7 +34,10 @@ class ControllerAWXKitModule(ControllerModule):
|
||||
|
||||
def authenticate(self):
|
||||
try:
|
||||
self.connection.login(username=self.username, password=self.password)
|
||||
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.authenticated = True
|
||||
except Exception:
|
||||
self.fail_json("Failed to authenticate")
|
||||
|
||||
@@ -99,6 +99,7 @@ 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'])
|
||||
),
|
||||
@@ -118,10 +119,12 @@ 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
|
||||
@@ -160,6 +163,8 @@ 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)
|
||||
@@ -186,6 +191,15 @@ 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("/"):
|
||||
@@ -284,7 +298,8 @@ 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 = {}
|
||||
for honorred_setting in self.short_params:
|
||||
# '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']:
|
||||
try:
|
||||
config_data[honorred_setting] = config.get('general', honorred_setting)
|
||||
except NoOptionError:
|
||||
@@ -296,6 +311,12 @@ 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:
|
||||
@@ -572,12 +593,7 @@ class ControllerAPIModule(ControllerModule):
|
||||
# Extract the headers, this will be used in a couple of places
|
||||
headers = kwargs.get('headers', {})
|
||||
|
||||
# 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()
|
||||
headers['Authorization'] = self._get_authorization_header(**kwargs)
|
||||
|
||||
if method in ['POST', 'PUT', 'PATCH']:
|
||||
headers.setdefault('Content-Type', 'application/json')
|
||||
@@ -761,6 +777,19 @@ 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)
|
||||
|
||||
138
awx_collection/test/awx/test_token_auth.py
Normal file
138
awx_collection/test/awx/test_token_auth.py
Normal file
@@ -0,0 +1,138 @@
|
||||
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
|
||||
13
pytest.ini
13
pytest.ini
@@ -15,19 +15,6 @@ 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.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Update aap-ci tekton-catalog pipeline bundles",
|
||||
"matchPackageNames": ["/^quay\\.io\\/aap-ci\\/tekton-catalog\\/pipeline\\//"],
|
||||
"matchManagers": ["tekton"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>=35.0.0
|
||||
kubernetes>=36.0.0 # fixes NO_PROXY silently being reset to None
|
||||
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==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)
|
||||
pyparsing>3.0 # Upgraded to v3 and changed import patterns
|
||||
python-daemon
|
||||
python-dsv-sdk>=1.0.4
|
||||
python-tss-sdk>=1.2.1
|
||||
|
||||
@@ -10,6 +10,7 @@ aiohttp[speedups]==3.13.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# aiohttp-retry
|
||||
# kubernetes
|
||||
# opa-python-client
|
||||
# twilio
|
||||
aiohttp-retry==2.9.1
|
||||
@@ -251,7 +252,7 @@ jsonschema==4.25.1
|
||||
# drf-spectacular
|
||||
jsonschema-specifications==2025.9.1
|
||||
# via jsonschema
|
||||
kubernetes==35.0.0
|
||||
kubernetes==36.0.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# openshift
|
||||
@@ -392,7 +393,7 @@ pyopenssl==25.3.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# twisted
|
||||
pyparsing==2.4.7
|
||||
pyparsing==3.3.2
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
python-daemon==3.1.2
|
||||
# via
|
||||
|
||||
Reference in New Issue
Block a user