Compare commits

...

18 Commits

Author SHA1 Message Date
Elijah DeLee
d65ab1c5ac add serializer stuff so shows up in DRF 2025-03-18 09:14:55 -04:00
Elijah DeLee
a2decc7c60 fix lint 2025-03-18 09:14:55 -04:00
Elijah DeLee
97d03e434e Add concept of priority to job templates and jobs
This adds concept of priority to jobs.
The task manager now orders on priority, then created.
All rules around instance group capacity etc still apply. So even if a
job has very high priority, if there is not available capacity in the
available instance groups, it will not be scheduled.

Higher number is higher priority.
Default priority is 0.

For dependencies spawned from other jobs, assign them the priority of
the job that caused them to be created.

Still need to add prompt on launch stuff for priority to be consistent.
2025-03-18 09:14:55 -04:00
Hao Liu
628a0e6a36 Add opa_query_path to Organization/Inventory/JobTemplate (#15863) 2025-03-18 09:06:14 -04:00
Eric C Chong
8fb5862223 Allow lookup_organization to find teams and resources from different orgs 2025-03-12 13:32:51 -04:00
Sasa Jovicic
6f7d5ca8a3 Implement an option to choose a job type on relaunch (issue #14177) (#15249)
Allows changing the job type (run, check) when relaunching
a job by adding a "job_type" to the relaunch POST payload
2025-03-12 13:27:05 -04:00
Seth Foster
0f0f5aa289 Pass in private_data_dir when project update is on K8S
In OCP/K8S, projects run in the task pod's ee container. The private_data_dir is not extracted to /runner. Instead, the project update runs directly from the mounted in private_data_dir, e.g. /tmp/awx_1_abcd.

When injecting a credential that uses extra vars, we pass the private_data_dir as as the container_root, so that the correct command line argument is generated, e.g. "-e /tmp/awx_1_abcd/env/extra_var_file".

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
2025-03-11 23:12:10 -04:00
Alan Rominger
bc12fa2283 Fix indirect host counting task test race condition (#15871) 2025-03-11 14:46:39 -04:00
Pablo H.
03b37037d6 feat: awx community bug scrub script (#15869)
* feat: awx community bug scrub script

---------

Co-authored-by: thedoubl3j <jljacks93@gmail.com>
2025-03-11 14:07:54 -04:00
Alan Rominger
5668973d70 Allow schema generation on-demand (#15885) 2025-03-11 13:49:02 -04:00
Hao Liu
e6434454ce Fix CI schema gen (#15886)
Fix schema gen failure

```
ERROR: invalid empty ssh agent socket: make sure SSH_AUTH_SOCK is set
```
2025-03-11 16:57:36 +00:00
Alan Rominger
3ba9c026ea Pin drf-yasg to make api-test pass (#15887)
Ping drf-yasg to make api-test pass
2025-03-11 16:39:06 +00:00
Konstantin
a206ca22ec Change collection name back to awx 2025-03-11 10:14:30 +01:00
Konstantin Kuminsky
e961cbe46f Few minor changes in the lookup description 2025-03-11 10:14:30 +01:00
Bruno Rocha
0ffe04ed9c feat: Manage Django Settings with Dynaconf
Dynaconf is being added from DAB factory to load Django Settings
2025-03-07 10:18:27 -05:00
Alan Rominger
ee739b5fd9 Fix test flake due to host metric id enumeration (#15875) 2025-03-06 14:35:39 -05:00
Peter Braun
abc04e5c88 feat: do not count dark hosts as updated (#15872)
* feat: do not count dark hosts as updated

* update functional tests
2025-03-06 09:41:12 +01:00
Peter Braun
5b17e5c9c3 update: use singular form ANSIBLE_COLLECTIONS_PATH (#15841)
* update: use singular form ANSIBLE_COLLECTIONS_PATH

* update functional tests
2025-03-05 16:39:34 +01:00
59 changed files with 846 additions and 352 deletions

View File

@@ -19,6 +19,8 @@ exclude_also =
branch = True
omit =
awx/main/migrations/*
awx/settings/defaults.py
awx/settings/*_defaults.py
source =
.
source_pkgs =

View File

@@ -5,6 +5,7 @@ env:
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
on:
workflow_dispatch:
push:
branches:
- devel
@@ -28,12 +29,32 @@ jobs:
- name: Install python ${{ env.py_version }}
uses: actions/setup-python@v4
with:
python-version: ${{ env.py_version }}
python-version: ${{ env.py_version }}
- name: Log in to registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Generate placeholder SSH private key if SSH auth for private repos is not needed
id: generate_key
shell: bash
run: |
if [[ -z "${{ secrets.PRIVATE_GITHUB_KEY }}" ]]; then
ssh-keygen -t ed25519 -C "github-actions" -N "" -f ~/.ssh/id_ed25519
echo "SSH_PRIVATE_KEY<<EOF" >> $GITHUB_OUTPUT
cat ~/.ssh/id_ed25519 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
echo "SSH_PRIVATE_KEY<<EOF" >> $GITHUB_OUTPUT
echo "${{ secrets.PRIVATE_GITHUB_KEY }}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Add private GitHub key to SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ steps.generate_key.outputs.SSH_PRIVATE_KEY }}
- name: Pre-pull image to warm build cache
run: |
docker pull -q ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/} || :
@@ -56,5 +77,3 @@ jobs:
ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
ansible localhost -c local -m aws_s3 \
-a "src=${{ github.workspace }}/schema.json bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=put permission=public-read"

View File

@@ -62,7 +62,8 @@ else:
def prepare_env():
# Update the default settings environment variable based on current mode.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings.%s' % MODE)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings')
os.environ.setdefault('AWX_MODE', MODE)
# Hide DeprecationWarnings when running in production. Need to first load
# settings to apply our filter after Django's own warnings filter.
from django.conf import settings

View File

@@ -161,7 +161,7 @@ def get_view_description(view, html=False):
def get_default_schema():
if settings.SETTINGS_MODULE == 'awx.settings.development':
if settings.DYNACONF.is_development_mode:
from awx.api.swagger import schema_view
return schema_view

View File

@@ -682,11 +682,12 @@ class EmptySerializer(serializers.Serializer):
class UnifiedJobTemplateSerializer(BaseSerializer):
# As a base serializer, the capabilities prefetch is not used directly,
# instead they are derived from the Workflow Job Template Serializer and the Job Template Serializer, respectively.
priority = serializers.IntegerField(required=False, min_value=0, max_value=32000)
capabilities_prefetch = []
class Meta:
model = UnifiedJobTemplate
fields = ('*', 'last_job_run', 'last_job_failed', 'next_job_run', 'status', 'execution_environment')
fields = ('*', 'last_job_run', 'last_job_failed', 'next_job_run', 'status', 'priority', 'execution_environment')
def get_related(self, obj):
res = super(UnifiedJobTemplateSerializer, self).get_related(obj)
@@ -2996,6 +2997,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer):
'scm_branch',
'forks',
'limit',
'priority',
'verbosity',
'extra_vars',
'job_tags',
@@ -3118,6 +3120,7 @@ class JobTemplateMixin(object):
class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobOptionsSerializer):
show_capabilities = ['start', 'schedule', 'copy', 'edit', 'delete']
capabilities_prefetch = ['admin', 'execute', {'copy': ['project.use', 'inventory.use']}]
priority = serializers.IntegerField(required=False, min_value=0, max_value=32000)
status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False)
@@ -3125,6 +3128,7 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
model = JobTemplate
fields = (
'*',
'priority',
'host_config_key',
'ask_scm_branch_on_launch',
'ask_diff_mode_on_launch',
@@ -3252,6 +3256,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
'diff_mode',
'job_slice_number',
'job_slice_count',
'priority',
'webhook_service',
'webhook_credential',
'webhook_guid',
@@ -3352,11 +3357,17 @@ class JobRelaunchSerializer(BaseSerializer):
choices=[('all', _('No change to job limit')), ('failed', _('All failed and unreachable hosts'))],
write_only=True,
)
job_type = serializers.ChoiceField(
required=False,
allow_null=True,
choices=NEW_JOB_TYPE_CHOICES,
write_only=True,
)
credential_passwords = VerbatimField(required=True, write_only=True)
class Meta:
model = Job
fields = ('passwords_needed_to_start', 'retry_counts', 'hosts', 'credential_passwords')
fields = ('passwords_needed_to_start', 'retry_counts', 'hosts', 'job_type', 'credential_passwords')
def validate_credential_passwords(self, value):
pnts = self.instance.passwords_needed_to_start
@@ -3696,6 +3707,7 @@ class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer):
class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
priority = serializers.IntegerField(required=False, min_value=0, max_value=32000)
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
@@ -3716,6 +3728,7 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
'-controller_node',
'inventory',
'limit',
'priority',
'scm_branch',
'webhook_service',
'webhook_credential',
@@ -3833,6 +3846,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
job_type = serializers.ChoiceField(allow_blank=True, allow_null=True, required=False, default=None, choices=NEW_JOB_TYPE_CHOICES)
job_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
priority = serializers.IntegerField(required=False, min_value=0, max_value=32000)
skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
diff_mode = serializers.BooleanField(required=False, allow_null=True, default=None)
verbosity = serializers.ChoiceField(allow_null=True, required=False, default=None, choices=VERBOSITY_CHOICES)
@@ -3851,6 +3865,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
'job_tags',
'skip_tags',
'limit',
'priority',
'skip_tags',
'diff_mode',
'verbosity',
@@ -4344,6 +4359,7 @@ class JobLaunchSerializer(BaseSerializer):
job_type = serializers.ChoiceField(required=False, choices=NEW_JOB_TYPE_CHOICES, write_only=True)
skip_tags = serializers.CharField(required=False, write_only=True, allow_blank=True)
limit = serializers.CharField(required=False, write_only=True, allow_blank=True)
priority = serializers.IntegerField(required=False, write_only=False, min_value=0, max_value=32000)
verbosity = serializers.ChoiceField(required=False, choices=VERBOSITY_CHOICES, write_only=True)
execution_environment = serializers.PrimaryKeyRelatedField(queryset=ExecutionEnvironment.objects.all(), required=False, write_only=True)
labels = serializers.PrimaryKeyRelatedField(many=True, queryset=Label.objects.all(), required=False, write_only=True)
@@ -4361,6 +4377,7 @@ class JobLaunchSerializer(BaseSerializer):
'inventory',
'scm_branch',
'limit',
'priority',
'job_tags',
'skip_tags',
'job_type',
@@ -4546,6 +4563,7 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
extra_vars = VerbatimField(required=False, write_only=True)
inventory = serializers.PrimaryKeyRelatedField(queryset=Inventory.objects.all(), required=False, write_only=True)
limit = serializers.CharField(required=False, write_only=True, allow_blank=True)
priority = serializers.IntegerField(required=False, write_only=False, min_value=0, max_value=32000)
scm_branch = serializers.CharField(required=False, write_only=True, allow_blank=True)
workflow_job_template_data = serializers.SerializerMethodField()
@@ -4685,13 +4703,14 @@ class BulkJobLaunchSerializer(serializers.Serializer):
)
inventory = serializers.PrimaryKeyRelatedField(queryset=Inventory.objects.all(), required=False, write_only=True)
limit = serializers.CharField(write_only=True, required=False, allow_blank=False)
# priority = serializers.IntegerField(write_only=True, required=False, min_value=0, max_value=32000)
scm_branch = serializers.CharField(write_only=True, required=False, allow_blank=False)
skip_tags = serializers.CharField(write_only=True, required=False, allow_blank=False)
job_tags = serializers.CharField(write_only=True, required=False, allow_blank=False)
class Meta:
model = WorkflowJob
fields = ('name', 'jobs', 'description', 'extra_vars', 'organization', 'inventory', 'limit', 'scm_branch', 'skip_tags', 'job_tags')
fields = ('name', 'jobs', 'description', 'extra_vars', 'organization', 'inventory', 'limit', 'priority', 'scm_branch', 'skip_tags', 'job_tags')
read_only_fields = ()
def validate(self, attrs):

View File

@@ -3435,6 +3435,7 @@ class JobRelaunch(RetrieveAPIView):
copy_kwargs = {}
retry_hosts = serializer.validated_data.get('hosts', None)
job_type = serializer.validated_data.get('job_type', None)
if retry_hosts and retry_hosts != 'all':
if obj.status in ACTIVE_STATES:
return Response(
@@ -3455,6 +3456,8 @@ class JobRelaunch(RetrieveAPIView):
)
copy_kwargs['limit'] = ','.join(retry_host_list)
if job_type:
copy_kwargs['job_type'] = job_type
new_job = obj.copy_unified_job(**copy_kwargs)
result = new_job.signal_start(**serializer.validated_data['credential_passwords'])
if not result:

View File

@@ -10,7 +10,7 @@ from awx.api.generics import APIView, Response
from awx.api.permissions import AnalyticsPermission
from awx.api.versioning import reverse
from awx.main.utils import get_awx_version
from awx.main.utils.analytics_proxy import OIDCClient, DEFAULT_OIDC_ENDPOINT
from awx.main.utils.analytics_proxy import OIDCClient, DEFAULT_OIDC_TOKEN_ENDPOINT
from rest_framework import status
from collections import OrderedDict
@@ -205,7 +205,7 @@ class AnalyticsGenericView(APIView):
try:
rh_user = self._get_setting('REDHAT_USERNAME', None, ERROR_MISSING_USER)
rh_password = self._get_setting('REDHAT_PASSWORD', None, ERROR_MISSING_PASSWORD)
client = OIDCClient(rh_user, rh_password, DEFAULT_OIDC_ENDPOINT, ['api.console'])
client = OIDCClient(rh_user, rh_password, DEFAULT_OIDC_TOKEN_ENDPOINT, ['api.console'])
response = client.make_request(
method,
url,

View File

@@ -22,7 +22,7 @@ from ansible_base.lib.utils.db import advisory_lock
from awx.main.models import Job
from awx.main.access import access_registry
from awx.main.utils import get_awx_http_client_headers, set_environ, datetime_hook
from awx.main.utils.analytics_proxy import OIDCClient, DEFAULT_OIDC_ENDPOINT
from awx.main.utils.analytics_proxy import OIDCClient, DEFAULT_OIDC_TOKEN_ENDPOINT
__all__ = ['register', 'gather', 'ship']
@@ -379,7 +379,7 @@ def ship(path):
with set_environ(**settings.AWX_TASK_ENV):
if rh_user and rh_password:
try:
client = OIDCClient(rh_user, rh_password, DEFAULT_OIDC_ENDPOINT, ['api.console'])
client = OIDCClient(rh_user, rh_password, DEFAULT_OIDC_TOKEN_ENDPOINT, ['api.console'])
response = client.make_request("POST", url, headers=s.headers, files=files, verify=settings.INSIGHTS_CERT_PATH, timeout=(31, 31))
except requests.RequestException:
logger.error("Automation Analytics API request failed, trying base auth method")

View File

@@ -0,0 +1,46 @@
# Generated by Django 4.2.18 on 2025-03-17 16:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0196_indirect_managed_node_audit'),
]
operations = [
migrations.AddField(
model_name='inventory',
name='opa_query_path',
field=models.CharField(
blank=True,
default=None,
help_text='The query path for the OPA policy to evaluate prior to job execution. The query path should be formatted as package/rule.',
max_length=128,
null=True,
),
),
migrations.AddField(
model_name='jobtemplate',
name='opa_query_path',
field=models.CharField(
blank=True,
default=None,
help_text='The query path for the OPA policy to evaluate prior to job execution. The query path should be formatted as package/rule.',
max_length=128,
null=True,
),
),
migrations.AddField(
model_name='organization',
name='opa_query_path',
field=models.CharField(
blank=True,
default=None,
help_text='The query path for the OPA policy to evaluate prior to job execution. The query path should be formatted as package/rule.',
max_length=128,
null=True,
),
),
]

View File

@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0196_indirect_managed_node_audit'),
('main', '0197_add_opa_query_path'),
]
operations = [

View File

@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0197_delete_profile'),
('main', '0198_delete_profile'),
]
operations = [

View File

@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0198_remove_sso_app_content'),
('main', '0199_remove_sso_app_content'),
]
operations = [

View File

@@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0199_alter_inventorysource_source_and_more'),
('main', '0200_alter_inventorysource_source_and_more'),
]
operations = [

View File

@@ -8,7 +8,7 @@ from awx.main.migrations._create_system_jobs import delete_clear_tokens_sjt
class Migration(migrations.Migration):
dependencies = [
('main', '0200_alter_oauth2application_unique_together_and_more'),
('main', '0201_alter_oauth2application_unique_together_and_more'),
]
operations = [

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.16 on 2025-03-11 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0201_delete_token_cleanup_job'),
]
operations = [
migrations.AddField(
model_name='unifiedjob',
name='priority',
field=models.PositiveIntegerField(
default=0,
editable=False,
help_text='Relative priority to other jobs. The higher the number, the higher the priority. Jobs with equivalent prioirty are started based on available capacity and launch time.',
),
),
migrations.AddField(
model_name='unifiedjobtemplate',
name='priority',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -550,10 +550,10 @@ class CredentialType(CommonModelNameNotUnique):
# TODO: User "side-loaded" credential custom_injectors isn't supported
ManagedCredentialType.registry[ns] = SimpleNamespace(namespace=ns, name=plugin.name, kind='external', inputs=plugin.inputs, backend=plugin.backend)
def inject_credential(self, credential, env, safe_env, args, private_data_dir):
def inject_credential(self, credential, env, safe_env, args, private_data_dir, container_root=None):
from awx_plugins.interfaces._temporary_private_inject_api import inject_credential
inject_credential(self, credential, env, safe_env, args, private_data_dir)
inject_credential(self, credential, env, safe_env, args, private_data_dir, container_root=container_root)
class CredentialTypeHelper:

View File

@@ -565,7 +565,6 @@ class JobEvent(BasePlaybookEvent):
summaries = dict()
updated_hosts_list = list()
for host in hostnames:
updated_hosts_list.append(host.lower())
host_id = host_map.get(host)
if host_id not in existing_host_ids:
host_id = None
@@ -582,6 +581,12 @@ class JobEvent(BasePlaybookEvent):
summary.failed = bool(summary.dark or summary.failures)
summaries[(host_id, host)] = summary
# do not count dark / unreachable hosts as updated
if not bool(summary.dark):
updated_hosts_list.append(host.lower())
else:
logger.warning(f'host {host.lower()} is dark / unreachable, not marking it as updated')
JobHostSummary.objects.bulk_create(summaries.values())
# update the last_job_id and last_job_host_summary_id

View File

@@ -43,6 +43,7 @@ from awx.main.models.mixins import (
TaskManagerInventoryUpdateMixin,
RelatedJobsMixin,
CustomVirtualEnvMixin,
OpaQueryPathMixin,
)
from awx.main.models.notifications import (
NotificationTemplate,
@@ -68,7 +69,7 @@ class InventoryConstructedInventoryMembership(models.Model):
)
class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin, OpaQueryPathMixin):
"""
an inventory source contains lists and hosts.
"""

View File

@@ -51,6 +51,7 @@ from awx.main.models.mixins import (
RelatedJobsMixin,
WebhookMixin,
WebhookTemplateMixin,
OpaQueryPathMixin,
)
from awx.main.constants import JOB_VARIABLE_PREFIXES
@@ -192,7 +193,9 @@ class JobOptions(BaseModel):
return needed
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookTemplateMixin):
class JobTemplate(
UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookTemplateMixin, OpaQueryPathMixin
):
"""
A job template is a reusable job definition for applying a project (with
playbook) to an inventory source with a given credential.
@@ -295,6 +298,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
'organization',
'survey_passwords',
'labels',
'priority',
'credentials',
'job_slice_number',
'job_slice_count',
@@ -1172,7 +1176,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions):
@classmethod
def _get_unified_job_field_names(cls):
return ['name', 'description', 'organization', 'job_type', 'extra_vars']
return ['name', 'description', 'organization', 'priority', 'job_type', 'extra_vars']
def get_absolute_url(self, request=None):
return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request)

View File

@@ -42,6 +42,7 @@ __all__ = [
'TaskManagerInventoryUpdateMixin',
'ExecutionEnvironmentMixin',
'CustomVirtualEnvMixin',
'OpaQueryPathMixin',
]
@@ -692,3 +693,16 @@ class WebhookMixin(models.Model):
logger.debug("Webhook status update sent.")
else:
logger.error("Posting webhook status failed, code: {}\n" "{}\nPayload sent: {}".format(response.status_code, response.text, json.dumps(data)))
class OpaQueryPathMixin(models.Model):
class Meta:
abstract = True
opa_query_path = models.CharField(
max_length=128,
blank=True,
null=True,
default=None,
help_text=_("The query path for the OPA policy to evaluate prior to job execution. The query path should be formatted as package/rule."),
)

View File

@@ -22,12 +22,12 @@ from awx.main.models.rbac import (
ROLE_SINGLETON_SYSTEM_AUDITOR,
)
from awx.main.models.unified_jobs import UnifiedJob
from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin
from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, OpaQueryPathMixin
__all__ = ['Organization', 'Team', 'UserSessionMembership']
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin):
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, OpaQueryPathMixin):
"""
An organization is the basic unit of multi-tenancy divisions
"""

View File

@@ -354,7 +354,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
@classmethod
def _get_unified_job_field_names(cls):
return set(f.name for f in ProjectOptions._meta.fields) | set(['name', 'description', 'organization'])
return set(f.name for f in ProjectOptions._meta.fields) | set(['name', 'description', 'priority', 'organization'])
def clean_organization(self):
if self.pk:

View File

@@ -118,6 +118,11 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
default=None,
editable=False,
)
priority = models.PositiveIntegerField(
null=False,
default=0,
editable=True,
)
current_job = models.ForeignKey(
'UnifiedJob',
null=True,
@@ -585,6 +590,13 @@ class UnifiedJob(
default=None,
editable=False,
)
priority = models.PositiveIntegerField(
default=0,
editable=False,
help_text=_(
"Relative priority to other jobs. The higher the number, the higher the priority. Jobs with equivalent prioirty are started based on available capacity and launch time."
),
)
emitted_events = models.PositiveIntegerField(
default=0,
editable=False,

View File

@@ -416,7 +416,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase):
@classmethod
def _get_unified_job_field_names(cls):
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
['name', 'description', 'organization', 'survey_passwords', 'labels', 'limit', 'scm_branch', 'job_tags', 'skip_tags']
['name', 'description', 'organization', 'survey_passwords', 'labels', 'limit', 'scm_branch', 'priority', 'job_tags', 'skip_tags']
)
r.remove('char_prompts') # needed due to copying launch config to launch config
return r

View File

@@ -97,7 +97,7 @@ class TaskBase:
UnifiedJob.objects.filter(**filter_args)
.exclude(launch_type='sync')
.exclude(polymorphic_ctype_id=wf_approval_ctype_id)
.order_by('created')
.order_by('-priority', 'created')
.prefetch_related('dependent_jobs')
)
self.all_tasks = [t for t in qs]
@@ -286,7 +286,7 @@ class WorkflowManager(TaskBase):
@timeit
def get_tasks(self, filter_args):
self.all_tasks = [wf for wf in WorkflowJob.objects.filter(**filter_args)]
self.all_tasks = [wf for wf in WorkflowJob.objects.filter(**filter_args).order_by('-priority', 'created')]
@timeit
def _schedule(self):
@@ -336,12 +336,14 @@ class DependencyManager(TaskBase):
return bool(((update.finished + timedelta(seconds=cache_timeout))) < tz_now())
def get_or_create_project_update(self, project_id):
def get_or_create_project_update(self, task):
project_id = task.project_id
priority = task.priority
project = self.all_projects.get(project_id, None)
if project is not None:
latest_project_update = project.project_updates.filter(job_type='check').order_by("-created").first()
if self.should_update_again(latest_project_update, project.scm_update_cache_timeout):
project_task = project.create_project_update(_eager_fields=dict(launch_type='dependency'))
project_task = project.create_project_update(_eager_fields=dict(launch_type='dependency', priority=priority))
project_task.signal_start()
return [project_task]
else:
@@ -349,7 +351,7 @@ class DependencyManager(TaskBase):
return []
def gen_dep_for_job(self, task):
dependencies = self.get_or_create_project_update(task.project_id)
dependencies = self.get_or_create_project_update(task)
try:
start_args = json.loads(decrypt_field(task, field_name="start_args"))
@@ -361,7 +363,7 @@ class DependencyManager(TaskBase):
continue
latest_inventory_update = inventory_source.inventory_updates.order_by("-created").first()
if self.should_update_again(latest_inventory_update, inventory_source.update_cache_timeout):
inventory_task = inventory_source.create_inventory_update(_eager_fields=dict(launch_type='dependency'))
inventory_task = inventory_source.create_inventory_update(_eager_fields=dict(launch_type='dependency', priority=task.priority))
inventory_task.signal_start()
dependencies.append(inventory_task)
else:

View File

@@ -522,9 +522,13 @@ class BaseTask(object):
credentials = self.build_credentials_list(self.instance)
container_root = None
if settings.IS_K8S and isinstance(self.instance, ProjectUpdate):
container_root = private_data_dir
for credential in credentials:
if credential:
credential.credential_type.inject_credential(credential, env, self.safe_cred_env, args, private_data_dir)
credential.credential_type.inject_credential(credential, env, self.safe_cred_env, args, private_data_dir, container_root=container_root)
self.runner_callback.safe_env.update(self.safe_cred_env)
@@ -917,7 +921,6 @@ class RunJob(SourceControlMixin, BaseTask):
env['ANSIBLE_NET_AUTH_PASS'] = network_cred.get_input('authorize_password', default='')
path_vars = [
('ANSIBLE_COLLECTIONS_PATHS', 'collections_paths', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
('ANSIBLE_ROLES_PATH', 'roles_path', 'requirements_roles', '~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles'),
('ANSIBLE_COLLECTIONS_PATH', 'collections_path', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
]
@@ -1520,7 +1523,7 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask):
raise NotImplementedError('Cannot update file sources through the task system.')
if inventory_update.source == 'scm' and inventory_update.source_project_update:
env_key = 'ANSIBLE_COLLECTIONS_PATHS'
env_key = 'ANSIBLE_COLLECTIONS_PATH'
config_setting = 'collections_paths'
folder = 'requirements_collections'
default = '~/.ansible/collections:/usr/share/ansible/collections'
@@ -1538,12 +1541,12 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask):
paths = [config_values[config_setting]] + paths
paths = [os.path.join(CONTAINER_ROOT, folder)] + paths
env[env_key] = os.pathsep.join(paths)
if 'ANSIBLE_COLLECTIONS_PATHS' in env:
paths = env['ANSIBLE_COLLECTIONS_PATHS'].split(':')
if 'ANSIBLE_COLLECTIONS_PATH' in env:
paths = env['ANSIBLE_COLLECTIONS_PATH'].split(':')
else:
paths = ['~/.ansible/collections', '/usr/share/ansible/collections']
paths.append('/usr/share/automation-controller/collections')
env['ANSIBLE_COLLECTIONS_PATHS'] = os.pathsep.join(paths)
env['ANSIBLE_COLLECTIONS_PATH'] = os.pathsep.join(paths)
return env

View File

@@ -210,6 +210,39 @@ def test_disallowed_http_update_methods(put, patch, post, inventory, project, ad
patch(url=reverse('api:job_detail', kwargs={'pk': job.pk}), data={}, user=admin_user, expect=405)
@pytest.mark.django_db
@pytest.mark.parametrize(
"job_type",
[
'run',
'check',
],
)
def test_job_relaunch_with_job_type(post, inventory, project, machine_credential, admin_user, job_type):
# Create a job template
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project)
# Set initial job type
init_job_type = 'check' if job_type == 'run' else 'run'
# Create a job instance
job = jt.create_unified_job(_eager_fields={'job_type': init_job_type})
# Perform the POST request
url = reverse('api:job_relaunch', kwargs={'pk': job.pk})
r = post(url=url, data={'job_type': job_type}, user=admin_user, expect=201)
# Assert that the response status code is 201 (Created)
assert r.status_code == 201
# Retrieve the newly created job from the response
new_job_id = r.data.get('id')
new_job = Job.objects.get(id=new_job_id)
# Assert that the new job has the correct job type
assert new_job.job_type == job_type
class TestControllerNode:
@pytest.fixture
def project_update(self, project):

View File

@@ -135,8 +135,9 @@ class TestEvents:
self._create_job_event(ok=dict((hostname, len(hostname)) for hostname in self.hostnames))
# Soft delete 6 host metrics
for hm in HostMetric.objects.filter(id__in=[1, 3, 5, 7, 9, 11]):
# Soft delete 6 of the 12 host metrics, every even host like "Host 2" or "Host 4"
for host_name in self.hostnames[::2]:
hm = HostMetric.objects.get(hostname=host_name.lower())
hm.soft_delete()
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=0) & Q(last_deleted__isnull=True))) == 6
@@ -165,7 +166,9 @@ class TestEvents:
skipped=dict((hostname, len(hostname)) for hostname in self.hostnames[10:12]),
)
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=0) & Q(last_deleted__isnull=True))) == 6
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=1) & Q(last_deleted__isnull=False))) == 6
# one of those 6 hosts is dark, so will not be counted
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=1) & Q(last_deleted__isnull=False))) == 5
def _generate_hosts(self, cnt, id_from=0):
self.hostnames = [f'Host {i}' for i in range(id_from, id_from + cnt)]

View File

@@ -231,7 +231,7 @@ def test_inventory_update_injected_content(product_name, this_kind, inventory, f
len([True for k in content.keys() if k.endswith(inventory_filename)]) > 0
), f"'{inventory_filename}' file not found in inventory update runtime files {content.keys()}"
env.pop('ANSIBLE_COLLECTIONS_PATHS', None) # collection paths not relevant to this test
env.pop('ANSIBLE_COLLECTIONS_PATH', None)
base_dir = os.path.join(DATA, 'plugins')
if not os.path.exists(base_dir):
os.mkdir(base_dir)

View File

@@ -50,13 +50,14 @@ def test_indirect_host_counting(live_tmp_folder, run_job_from_playbook):
job.refresh_from_db()
if job.event_queries_processed is False:
save_indirect_host_entries.delay(job.id, wait_for_events=False)
# This will poll for the background task to finish
for _ in range(10):
if IndirectManagedNodeAudit.objects.filter(job=job).exists():
break
time.sleep(0.2)
else:
raise RuntimeError(f'No IndirectManagedNodeAudit records ever populated for job_id={job.id}')
# event_queries_processed only assures the task has started, it might take a minor amount of time to finish
for _ in range(10):
if IndirectManagedNodeAudit.objects.filter(job=job).exists():
break
time.sleep(0.2)
else:
raise RuntimeError(f'No IndirectManagedNodeAudit records ever populated for job_id={job.id}')
assert IndirectManagedNodeAudit.objects.filter(job=job).count() == 1
host_audit = IndirectManagedNodeAudit.objects.filter(job=job).first()

View File

@@ -1,6 +1,3 @@
from split_settings.tools import include
LOCAL_SETTINGS = (
'ALLOWED_HOSTS',
'BROADCAST_WEBSOCKET_PORT',
@@ -16,13 +13,14 @@ LOCAL_SETTINGS = (
def test_postprocess_auth_basic_enabled():
locals().update({'__file__': __file__})
"""The final loaded settings should have basic auth enabled."""
from awx.settings import REST_FRAMEWORK
include('../../../settings/defaults.py', scope=locals())
assert 'awx.api.authentication.LoggedBasicAuthentication' in locals()['REST_FRAMEWORK']['DEFAULT_AUTHENTICATION_CLASSES']
assert 'awx.api.authentication.LoggedBasicAuthentication' in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES']
def test_default_settings():
"""Ensure that all default settings are present in the snapshot."""
from django.conf import settings
for k in dir(settings):
@@ -31,3 +29,43 @@ def test_default_settings():
default_val = getattr(settings.default_settings, k, None)
snapshot_val = settings.DEFAULTS_SNAPSHOT[k]
assert default_val == snapshot_val, f'Setting for {k} does not match shapshot:\nsnapshot: {snapshot_val}\ndefault: {default_val}'
def test_django_conf_settings_is_awx_settings():
"""Ensure that the settings loaded from dynaconf are the same as the settings delivered to django."""
from django.conf import settings
from awx.settings import REST_FRAMEWORK
assert settings.REST_FRAMEWORK == REST_FRAMEWORK
def test_dynaconf_is_awx_settings():
"""Ensure that the settings loaded from dynaconf are the same as the settings delivered to django."""
from django.conf import settings
from awx.settings import REST_FRAMEWORK
assert settings.DYNACONF.REST_FRAMEWORK == REST_FRAMEWORK
def test_development_settings_can_be_directly_imported(monkeypatch):
"""Ensure that the development settings can be directly imported."""
monkeypatch.setenv('AWX_MODE', 'development')
from django.conf import settings
from awx.settings.development import REST_FRAMEWORK
from awx.settings.development import DEBUG # actually set on defaults.py and not overridden in development.py
assert settings.REST_FRAMEWORK == REST_FRAMEWORK
assert DEBUG is True
def test_merge_application_name():
"""Ensure that the merge_application_name function works as expected."""
from awx.settings.functions import merge_application_name
settings = {
"DATABASES__default__ENGINE": "django.db.backends.postgresql",
"CLUSTER_HOST_ID": "test-cluster-host-id",
}
result = merge_application_name(settings)["DATABASES__default__OPTIONS__application_name"]
assert result.startswith("awx-")
assert "test-cluster" in result

View File

@@ -10,7 +10,7 @@ from typing import Optional, Any
import requests
DEFAULT_OIDC_ENDPOINT = 'https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token'
DEFAULT_OIDC_TOKEN_ENDPOINT = 'https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token'
class TokenError(requests.RequestException):

View File

@@ -201,7 +201,7 @@
# additional_galaxy_env contains environment variables are used for installing roles and collections and will take precedence over items in galaxy_task_env
additional_galaxy_env:
# These paths control where ansible-galaxy installs collections and roles on top the filesystem
ANSIBLE_COLLECTIONS_PATHS: "{{ projects_root }}/.__awx_cache/{{ local_path }}/stage/requirements_collections"
ANSIBLE_COLLECTIONS_PATH: "{{ projects_root }}/.__awx_cache/{{ local_path }}/stage/requirements_collections"
ANSIBLE_ROLES_PATH: "{{ projects_root }}/.__awx_cache/{{ local_path }}/stage/requirements_roles"
# Put the local tmp directory in same volume as collection destination
# otherwise, files cannot be moved accross volumes and will cause error

View File

@@ -1,2 +1,82 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import os
import copy
from ansible_base.lib.dynamic_config import (
factory,
export,
load_envvars,
load_python_file_with_injected_context,
load_standard_settings_files,
toggle_feature_flags,
)
from .functions import (
assert_production_settings,
merge_application_name,
add_backwards_compatibility,
load_extra_development_files,
)
add_backwards_compatibility()
# Create a the standard DYNACONF instance which will come with DAB defaults
# This loads defaults.py and environment specific file e.g: development_defaults.py
DYNACONF = factory(
__name__,
"AWX",
environments=("development", "production", "quiet", "kube"),
settings_files=["defaults.py"],
)
# Store snapshot before loading any custom config file
DYNACONF.set(
"DEFAULTS_SNAPSHOT",
copy.deepcopy(DYNACONF.as_dict(internal=False)),
loader_identifier="awx.settings:DEFAULTS_SNAPSHOT",
)
#############################################################################################
# Settings loaded before this point will be allowed to be overridden by the database settings
# Any settings loaded after this point will be marked as as a read_only database setting
#############################################################################################
# Load extra settings files from the following directories
# /etc/tower/conf.d/ and /etc/tower/
# this is the legacy location, kept for backwards compatibility
settings_dir = os.environ.get('AWX_SETTINGS_DIR', '/etc/tower/conf.d/')
settings_files_path = os.path.join(settings_dir, '*.py')
settings_file_path = os.environ.get('AWX_SETTINGS_FILE', '/etc/tower/settings.py')
load_python_file_with_injected_context(settings_files_path, settings=DYNACONF)
load_python_file_with_injected_context(settings_file_path, settings=DYNACONF)
# Load extra settings files from the following directories
# /etc/ansible-automation-platform/{settings,flags,.secrets}.yaml
# and /etc/ansible-automation-platform/awx/{settings,flags,.secrets}.yaml
# this is the new standard location for all services
load_standard_settings_files(DYNACONF)
# Load optional development only settings files
load_extra_development_files(DYNACONF)
# Check at least one setting file has been loaded in production mode
assert_production_settings(DYNACONF, settings_dir, settings_file_path)
# Load envvars at the end to allow them to override everything loaded so far
load_envvars(DYNACONF)
# This must run after all custom settings are loaded
DYNACONF.update(
merge_application_name(DYNACONF),
loader_identifier="awx.settings:merge_application_name",
merge=True,
)
# Toggle feature flags based on installer settings
DYNACONF.update(
toggle_feature_flags(DYNACONF),
loader_identifier="awx.settings:toggle_feature_flags",
merge=True,
)
# Update django.conf.settings with DYNACONF values
export(__name__, DYNACONF)

View File

@@ -25,6 +25,7 @@ def get_application_name(CLUSTER_HOST_ID, function=''):
def set_application_name(DATABASES, CLUSTER_HOST_ID, function=''):
"""In place modification of DATABASES to set the application name for the connection."""
# If settings files were not properly passed DATABASES could be {} at which point we don't need to set the app name.
if not DATABASES or 'default' not in DATABASES:
return

View File

@@ -9,9 +9,6 @@ import tempfile
import socket
from datetime import timedelta
from split_settings.tools import include
DEBUG = True
SQL_DEBUG = DEBUG
@@ -1015,16 +1012,15 @@ METRICS_SUBSYSTEM_CONFIG = {
}
}
# django-ansible-base
ANSIBLE_BASE_TEAM_MODEL = 'main.Team'
ANSIBLE_BASE_ORGANIZATION_MODEL = 'main.Organization'
ANSIBLE_BASE_RESOURCE_CONFIG_MODULE = 'awx.resource_api'
ANSIBLE_BASE_PERMISSION_MODEL = 'main.Permission'
from ansible_base.lib import dynamic_config # noqa: E402
include(os.path.join(os.path.dirname(dynamic_config.__file__), 'dynamic_settings.py'))
# Defaults to be overridden by DAB
SPECTACULAR_SETTINGS = {}
OAUTH2_PROVIDER = {}
# Add a postfix to the API URL patterns
# example if set to '' API pattern will be /api

View File

@@ -1,129 +1,13 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Development settings for AWX project.
# Python
# This file exists for backwards compatibility only
# the current way of running AWX is to point settings to
# awx/settings/__init__.py as the entry point for the settings
# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings
import os
import socket
import copy
import sys
import traceback
# Centos-7 doesn't include the svg mime type
# /usr/lib64/python/mimetypes.py
import mimetypes
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings")
os.environ.setdefault("AWX_MODE", "development")
# Django Split Settings
from split_settings.tools import optional, include
from ansible_base.lib.dynamic_config import export
from . import DYNACONF # noqa
# Load default settings.
from .defaults import * # NOQA
# awx-manage shell_plus --notebook
NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '9888', '--allow-root', '--no-browser']
# print SQL queries in shell_plus
SHELL_PLUS_PRINT_SQL = False
# show colored logs in the dev environment
# to disable this, set `COLOR_LOGS = False` in awx/settings/local_settings.py
COLOR_LOGS = True
LOGGING['handlers']['console']['()'] = 'awx.main.utils.handlers.ColorHandler' # noqa
ALLOWED_HOSTS = ['*']
mimetypes.add_type("image/svg+xml", ".svg", True)
mimetypes.add_type("image/svg+xml", ".svgz", True)
# Disallow sending session cookies over insecure connections
SESSION_COOKIE_SECURE = False
# Disallow sending csrf cookies over insecure connections
CSRF_COOKIE_SECURE = False
# Disable Pendo on the UI for development/test.
# Note: This setting may be overridden by database settings.
PENDO_TRACKING_STATE = "off"
INSIGHTS_TRACKING_STATE = False
# debug toolbar and swagger assume that requirements/requirements_dev.txt are installed
INSTALLED_APPS += ['drf_yasg', 'debug_toolbar'] # NOQA
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE # NOQA
DEBUG_TOOLBAR_CONFIG = {'ENABLE_STACKTRACES': True}
# Configure a default UUID for development only.
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
INSTALL_UUID = '00000000-0000-0000-0000-000000000000'
# Ansible base virtualenv paths and enablement
# only used for deprecated fields and management commands for them
BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv")
CLUSTER_HOST_ID = socket.gethostname()
AWX_CALLBACK_PROFILE = True
# this modifies FLAGS set by defaults
FLAGS['FEATURE_INDIRECT_NODE_COUNTING_ENABLED'] = [{'condition': 'boolean', 'value': True}] # noqa
# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!=================================
# Disable normal scheduled/triggered task managers (DependencyManager, TaskManager, WorkflowManager).
# Allows user to trigger task managers directly for debugging and profiling purposes.
# Only works in combination with settings.SETTINGS_MODULE == 'awx.settings.development'
AWX_DISABLE_TASK_MANAGERS = False
# Needed for launching runserver in debug mode
# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!=================================
# Store a snapshot of default settings at this point before loading any
# customizable config files.
this_module = sys.modules[__name__]
local_vars = dir(this_module)
DEFAULTS_SNAPSHOT = {} # define after we save local_vars so we do not snapshot the snapshot
for setting in local_vars:
if setting.isupper():
DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting))
del local_vars # avoid temporary variables from showing up in dir(settings)
del this_module
#
###############################################################################################
#
# Any settings defined after this point will be marked as as a read_only database setting
#
################################################################################################
# If there is an `/etc/tower/settings.py`, include it.
# If there is a `/etc/tower/conf.d/*.py`, include them.
include(optional('/etc/tower/settings.py'), scope=locals())
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
# If any local_*.py files are present in awx/settings/, use them to override
# default settings for development. If not present, we can still run using
# only the defaults.
# this needs to stay at the bottom of this file
try:
if os.getenv('AWX_KUBE_DEVEL', False):
include(optional('development_kube.py'), scope=locals())
else:
include(optional('local_*.py'), scope=locals())
except ImportError:
traceback.print_exc()
sys.exit(1)
# The below runs AFTER all of the custom settings are imported
# because conf.d files will define DATABASES and this should modify that
from .application_name import set_application_name
set_application_name(DATABASES, CLUSTER_HOST_ID) # NOQA
del set_application_name
# Set the value of any feature flags that are defined in the local settings
for feature in list(FLAGS.keys()): # noqa: F405
if feature in locals():
FLAGS[feature][0]['value'] = locals()[feature] # noqa: F405
export(__name__, DYNACONF)

View File

@@ -0,0 +1,76 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Development settings for AWX project.
# Python
import os
import socket
# Centos-7 doesn't include the svg mime type
# /usr/lib64/python/mimetypes.py
import mimetypes
from dynaconf import post_hook
# awx-manage shell_plus --notebook
NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '9888', '--allow-root', '--no-browser']
# print SQL queries in shell_plus
SHELL_PLUS_PRINT_SQL = False
# show colored logs in the dev environment
# to disable this, set `COLOR_LOGS = False` in awx/settings/local_settings.py
COLOR_LOGS = True
LOGGING__handlers__console = '@merge {"()": "awx.main.utils.handlers.ColorHandler"}'
ALLOWED_HOSTS = ['*']
mimetypes.add_type("image/svg+xml", ".svg", True)
mimetypes.add_type("image/svg+xml", ".svgz", True)
# Disallow sending session cookies over insecure connections
SESSION_COOKIE_SECURE = False
# Disallow sending csrf cookies over insecure connections
CSRF_COOKIE_SECURE = False
# Disable Pendo on the UI for development/test.
# Note: This setting may be overridden by database settings.
PENDO_TRACKING_STATE = "off"
INSIGHTS_TRACKING_STATE = False
# debug toolbar and swagger assume that requirements/requirements_dev.txt are installed
INSTALLED_APPS = "@merge drf_yasg,debug_toolbar"
MIDDLEWARE = "@insert 0 debug_toolbar.middleware.DebugToolbarMiddleware"
DEBUG_TOOLBAR_CONFIG = {'ENABLE_STACKTRACES': True}
# Configure a default UUID for development only.
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
INSTALL_UUID = '00000000-0000-0000-0000-000000000000'
# Ansible base virtualenv paths and enablement
# only used for deprecated fields and management commands for them
BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv")
CLUSTER_HOST_ID = socket.gethostname()
AWX_CALLBACK_PROFILE = True
# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!=================================
# Disable normal scheduled/triggered task managers (DependencyManager, TaskManager, WorkflowManager).
# Allows user to trigger task managers directly for debugging and profiling purposes.
# Only works in combination with settings.SETTINGS_MODULE == 'awx.settings.development'
AWX_DISABLE_TASK_MANAGERS = False
# Needed for launching runserver in debug mode
# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!=================================
# This modifies FLAGS set by defaults, must be deferred to run later
@post_hook
def set_dev_flags(settings):
defaults_flags = settings.get("FLAGS", {})
defaults_flags['FEATURE_INDIRECT_NODE_COUNTING_ENABLED'] = [{'condition': 'boolean', 'value': True}]
return {'FLAGS': defaults_flags}

View File

@@ -1,4 +1,13 @@
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
BROADCAST_WEBSOCKET_PORT = 8052
BROADCAST_WEBSOCKET_VERIFY_CERT = False
BROADCAST_WEBSOCKET_PROTOCOL = 'http'
# This file exists for backwards compatibility only
# the current way of running AWX is to point settings to
# awx/settings/__init__.py as the entry point for the settings
# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings")
os.environ.setdefault("AWX_MODE", "development,kube")
from ansible_base.lib.dynamic_config import export
from . import DYNACONF # noqa
export(__name__, DYNACONF)

View File

@@ -1,15 +1,13 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# This file exists for backwards compatibility only
# the current way of running AWX is to point settings to
# awx/settings/__init__.py as the entry point for the settings
# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings
import os
# Development settings for AWX project, but with DEBUG disabled
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings")
os.environ.setdefault("AWX_MODE", "development,quiet")
# Load development settings.
from defaults import * # NOQA
from ansible_base.lib.dynamic_config import export
from . import DYNACONF # noqa
# Load development settings.
from development import * # NOQA
# Disable capturing DEBUG
DEBUG = False
TEMPLATE_DEBUG = DEBUG
SQL_DEBUG = DEBUG
export(__name__, DYNACONF)

86
awx/settings/functions.py Normal file
View File

@@ -0,0 +1,86 @@
import os
from ansible_base.lib.dynamic_config import load_python_file_with_injected_context
from dynaconf import Dynaconf
from .application_name import get_application_name
def merge_application_name(settings):
"""Return a dynaconf merge dict to set the application name for the connection."""
data = {}
if "sqlite3" not in settings.get("DATABASES__default__ENGINE", ""):
data["DATABASES__default__OPTIONS__application_name"] = get_application_name(settings.get("CLUSTER_HOST_ID"))
return data
def add_backwards_compatibility():
"""Add backwards compatibility for AWX_MODE.
Before dynaconf integration the usage of AWX settings was supported to be just
DJANGO_SETTINGS_MODULE=awx.settings.production or DJANGO_SETTINGS_MODULE=awx.settings.development
(development_quiet and development_kube were also supported).
With dynaconf the DJANGO_SETTINGS_MODULE should be set always to "awx.settings" as the only entry point
for settings and then "AWX_MODE" can be set to any of production,development,quiet,kube
or a combination of them separated by comma.
E.g:
export DJANGO_SETTINGS_MODULE=awx.settings
export AWX_MODE=production
awx-manage [command]
dynaconf [command]
If pointing `DJANGO_SETTINGS_MODULE` to `awx.settings.production` or `awx.settings.development` then
this function will set `AWX_MODE` to the correct value.
"""
django_settings_module = os.getenv("DJANGO_SETTINGS_MODULE", "awx.settings")
if django_settings_module == "awx.settings":
return
current_mode = os.getenv("AWX_MODE", "")
for _module_name in ["development", "production", "development_quiet", "development_kube"]:
if django_settings_module == f"awx.settings.{_module_name}":
_mode = current_mode.split(",")
if "development_" in _module_name and "development" not in current_mode:
_mode.append("development")
_mode_fragment = _module_name.replace("development_", "")
if _mode_fragment not in _mode:
_mode.append(_mode_fragment)
os.environ["AWX_MODE"] = ",".join(_mode)
def load_extra_development_files(settings: Dynaconf):
"""Load optional development only settings files."""
if not settings.is_development_mode:
return
if settings.get_environ("AWX_KUBE_DEVEL"):
load_python_file_with_injected_context("kube_defaults.py", settings=settings)
else:
load_python_file_with_injected_context("local_*.py", settings=settings)
def assert_production_settings(settings: Dynaconf, settings_dir: str, settings_file_path: str): # pragma: no cover
"""Ensure at least one setting file has been loaded in production mode.
Current systems will require /etc/tower/settings.py and
new systems will require /etc/ansible-automation-platform/*.yaml
"""
if "production" not in settings.current_env.lower():
return
required_settings_paths = [
os.path.dirname(settings_file_path),
"/etc/ansible-automation-platform/",
settings_dir,
]
for path in required_settings_paths:
if any([path in os.path.dirname(f) for f in settings._loaded_files]):
break
else:
from django.core.exceptions import ImproperlyConfigured # noqa
msg = 'No AWX configuration found at %s.' % required_settings_paths
msg += '\nDefine the AWX_SETTINGS_FILE environment variable to '
msg += 'specify an alternate path.'
raise ImproperlyConfigured(msg)

View File

@@ -0,0 +1,4 @@
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
BROADCAST_WEBSOCKET_PORT = 8052
BROADCAST_WEBSOCKET_VERIFY_CERT = False
BROADCAST_WEBSOCKET_PROTOCOL = 'http'

View File

@@ -1,111 +1,13 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Production settings for AWX project.
# Python
# This file exists for backwards compatibility only
# the current way of running AWX is to point settings to
# awx/settings/__init__.py as the entry point for the settings
# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings
import os
import copy
import errno
import sys
import traceback
# Django Split Settings
from split_settings.tools import optional, include
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings")
os.environ.setdefault("AWX_MODE", "production")
# Load default settings.
from .defaults import * # NOQA
from ansible_base.lib.dynamic_config import export
from . import DYNACONF # noqa
DEBUG = False
TEMPLATE_DEBUG = DEBUG
SQL_DEBUG = DEBUG
# Clear database settings to force production environment to define them.
DATABASES = {}
# Clear the secret key to force production environment to define it.
SECRET_KEY = None
# Hosts/domain names that are valid for this site; required if DEBUG is False
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = []
# Ansible base virtualenv paths and enablement
# only used for deprecated fields and management commands for them
BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv")
# Very important that this is editable (not read_only) in the API
AWX_ISOLATION_SHOW_PATHS = [
'/etc/pki/ca-trust:/etc/pki/ca-trust:O',
'/usr/share/pki:/usr/share/pki:O',
]
# Store a snapshot of default settings at this point before loading any
# customizable config files.
this_module = sys.modules[__name__]
local_vars = dir(this_module)
DEFAULTS_SNAPSHOT = {} # define after we save local_vars so we do not snapshot the snapshot
for setting in local_vars:
if setting.isupper():
DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting))
del local_vars # avoid temporary variables from showing up in dir(settings)
del this_module
#
###############################################################################################
#
# Any settings defined after this point will be marked as as a read_only database setting
#
################################################################################################
# Load settings from any .py files in the global conf.d directory specified in
# the environment, defaulting to /etc/tower/conf.d/.
settings_dir = os.environ.get('AWX_SETTINGS_DIR', '/etc/tower/conf.d/')
settings_files = os.path.join(settings_dir, '*.py')
# Load remaining settings from the global settings file specified in the
# environment, defaulting to /etc/tower/settings.py.
settings_file = os.environ.get('AWX_SETTINGS_FILE', '/etc/tower/settings.py')
# Attempt to load settings from /etc/tower/settings.py first, followed by
# /etc/tower/conf.d/*.py.
try:
include(settings_file, optional(settings_files), scope=locals())
except ImportError:
traceback.print_exc()
sys.exit(1)
except IOError:
from django.core.exceptions import ImproperlyConfigured
included_file = locals().get('__included_file__', '')
if not included_file or included_file == settings_file:
# The import doesn't always give permission denied, so try to open the
# settings file directly.
try:
e = None
open(settings_file)
except IOError:
pass
if e and e.errno == errno.EACCES:
SECRET_KEY = 'permission-denied'
LOGGING = {}
else:
msg = 'No AWX configuration found at %s.' % settings_file
msg += '\nDefine the AWX_SETTINGS_FILE environment variable to '
msg += 'specify an alternate path.'
raise ImproperlyConfigured(msg)
else:
raise
# The below runs AFTER all of the custom settings are imported
# because conf.d files will define DATABASES and this should modify that
from .application_name import set_application_name
set_application_name(DATABASES, CLUSTER_HOST_ID) # NOQA
del set_application_name
# Set the value of any feature flags that are defined in the local settings
for feature in list(FLAGS.keys()): # noqa: F405
if feature in locals():
FLAGS[feature][0]['value'] = locals()[feature] # noqa: F405
export(__name__, DYNACONF)

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Production settings for AWX project.
import os
DEBUG = False
TEMPLATE_DEBUG = DEBUG
SQL_DEBUG = DEBUG
# Clear database settings to force production environment to define them.
DATABASES = {}
# Clear the secret key to force production environment to define it.
SECRET_KEY = None
# Hosts/domain names that are valid for this site; required if DEBUG is False
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = []
# Ansible base virtualenv paths and enablement
# only used for deprecated fields and management commands for them
BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv")
# Very important that this is editable (not read_only) in the API
AWX_ISOLATION_SHOW_PATHS = [
'/etc/pki/ca-trust:/etc/pki/ca-trust:O',
'/usr/share/pki:/usr/share/pki:O',
]

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Development settings for AWX project, but with DEBUG disabled
# Disable capturing DEBUG
DEBUG = False
TEMPLATE_DEBUG = DEBUG
SQL_DEBUG = DEBUG

View File

@@ -37,7 +37,7 @@ def get_urlpatterns(prefix=None):
re_path(r'^(?!api/).*', include('awx.ui.urls', namespace='ui')),
]
if settings.SETTINGS_MODULE == 'awx.settings.development':
if settings.DYNACONF.is_development_mode:
try:
import debug_toolbar

View File

@@ -29,7 +29,7 @@ DOCUMENTATION = """
description:
- The date to start the rule
- Used for all frequencies
- Format should be YYYY-MM-DD [HH:MM:SS]
- Format should be 'YYYY-MM-DD HH:MM:SS'
type: str
timezone:
description:
@@ -47,8 +47,8 @@ DOCUMENTATION = """
description:
- How to end this schedule
- If this is not defined, this schedule will never end
- If this is a positive integer, this schedule will end after this number of occurences
- If this is a date in the format YYYY-MM-DD [HH:MM:SS], this schedule ends after this date
- If this is a positive number, specified as a string, this schedule will end after this number of occurrences
- If this is a date in the format 'YYYY-MM-DD HH:MM:SS', this schedule ends after this date
- Used for all types except none
type: str
on_days:

View File

@@ -268,7 +268,7 @@ def main():
for resource in value:
# Attempt to look up project based on the provided name, ID, or named URL and lookup data
lookup_key = key
if key == 'organizations' or key == 'users':
if key == 'organizations' or key == 'users' or key == 'teams':
lookup_data_populated = {}
else:
lookup_data_populated = lookup_data

View File

@@ -13,6 +13,7 @@
wfjt_name: "AWX-Collection-tests-role-project-wfjt-{{ test_id }}"
team_name: "AWX-Collection-tests-team-team-{{ test_id }}"
team2_name: "AWX-Collection-tests-team-team-{{ test_id }}2"
org2_name: "AWX-Collection-tests-organization-{{ test_id }}2"
- block:
- name: Create a User
@@ -209,6 +210,40 @@
that:
- "result is changed"
- name: Create a 2nd organization
organization:
name: "{{ org2_name }}"
- name: Create a project in 2nd Organization
project:
name: "{{ project_name }}"
organization: "{{ org2_name }}"
scm_type: git
scm_url: https://github.com/ansible/test-playbooks
wait: true
register: project_info
- name: Add Joe and teams to the update role of the default Project with lookup from the 2nd Organization
role:
user: "{{ username }}"
users:
- "{{ username }}2"
teams:
- "{{ team_name }}"
- "{{ team2_name }}"
role: update
lookup_organization: "{{ org2_name }}"
project: "{{ project_name }}"
state: "{{ item }}"
register: result
with_items:
- "present"
- "absent"
- assert:
that:
- "result is changed"
always:
- name: Delete a User
user:
@@ -252,3 +287,16 @@
organization: Default
state: absent
register: result
- name: Delete the 2nd project
project:
name: "{{ project_name }}"
organization: "{{ org2_name }}"
state: absent
register: result
- name: Delete the 2nd organization
organization:
name: "{{ org2_name }}"
state: absent
register: result

View File

@@ -1,27 +0,0 @@
Copyright (c) 2013, 2General Oy
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of django-split-settings nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

21
licenses/dynaconf.txt Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Bruno Rocha
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -22,9 +22,9 @@ django-guid
django-oauth-toolkit<2.0.0 # Version 2.0.0 has breaking changes that will need to be worked out before upgrading
django-polymorphic
django-solo
django-split-settings
djangorestframework>=3.15.0
djangorestframework-yaml
dynaconf<4
filelock
GitPython>=3.1.37 # CVE-2023-41040
grpcio

View File

@@ -158,10 +158,6 @@ django-polymorphic==3.1.0
# via -r /awx_devel/requirements/requirements.in
django-solo==2.4.0
# via -r /awx_devel/requirements/requirements.in
django-split-settings==1.3.2
# via
# -r /awx_devel/requirements/requirements.in
# django-ansible-base
djangorestframework==3.15.2
# via
# -r /awx_devel/requirements/requirements.in
@@ -170,6 +166,8 @@ djangorestframework-yaml==2.0.0
# via -r /awx_devel/requirements/requirements.in
durationpy==0.9
# via kubernetes
dynaconf==3.2.10
# via -r /awx_devel/requirements/requirements.in
enum-compat==0.0.3
# via asn1
filelock==3.16.1

View File

@@ -2,7 +2,7 @@ build
coreapi
django-debug-toolbar==3.2.4
django-test-migrations
drf-yasg
drf-yasg<1.21.10 # introduces new DeprecationWarning that is turned into error
# pprofile - re-add once https://github.com/vpelletier/pprofile/issues/41 is addressed
ipython>=7.31.1 # https://github.com/ansible/awx/security/dependabot/30
unittest2

View File

@@ -1,6 +1,6 @@
git+https://github.com/ansible/system-certifi.git@devel#egg=certifi
# Remove pbr from requirements.in when moving ansible-runner to requirements.in
git+https://github.com/ansible/ansible-runner.git@devel#egg=ansible-runner
django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest-filters,jwt_consumer,resource-registry,rbac,feature-flags]
awx-plugins-core @ git+https://github.com/ansible/awx-plugins.git@devel#egg=awx-plugins-core[credentials-github-app]
django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest-filters,jwt_consumer,resource-registry,rbac,feature-flags]
awx_plugins.interfaces @ git+https://github.com/ansible/awx_plugins.interfaces.git

View File

@@ -0,0 +1,19 @@
# Community BugScrub tooling
Small python script that automatically distributes PRs and Issues given a list of `people` and dumps the contents in a Spreadsheet.
To be used when distributing the work of reviewing community contributions.
## Usage
Install requirements.
```
pip install -r requirements.txt
```
Get the usage.
```
python generate-sheet.py -h
```

View File

@@ -0,0 +1,125 @@
import argparse
import os
from typing import OrderedDict
import pyexcel
import requests
import sys
def get_headers():
access_token_env_var = "GITHUB_ACCESS_TOKEN"
if access_token_env_var in os.environ:
access_token = os.environ[access_token_env_var]
return {"Authorization": f"token {access_token}"}
else:
print(f"{access_token_env_var} not present, performing unathenticated calls that might hit rate limits.")
return None
def fetch_items(url, params, headers):
response = requests.get(url, params=params, headers=headers)
if response.status_code == 200:
return response
else:
print(f"Failed to fetch items: {response.status_code}", file=sys.stderr)
print(f"{response.content}", file=sys.stderr)
return None
def extract_next_url(response):
if 'Link' in response.headers:
links = response.headers['Link'].split(',')
for link in links:
if 'rel="next"' in link:
return link.split(';')[0].strip('<> ')
return None
def get_all_items(url, params, limit=None):
items = []
headers = get_headers()
while url:
response = fetch_items(url, params, headers)
if response:
items.extend(response.json())
print(f"Processing {len(items)}", file=sys.stderr)
if limit and len(items) > limit:
break
url = extract_next_url(response)
else:
url = None
return items
def get_open_issues(repo_url, limit):
owner, repo = repo_url.rstrip('/').split('/')[-2:]
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
params = {'state': 'open', 'per_page': 100}
issues = get_all_items(url, params, limit)
open_issues = [issue for issue in issues if 'pull_request' not in issue]
return open_issues
def get_open_pull_requests(repo_url, limit):
owner, repo = repo_url.rstrip('/').split('/')[-2:]
url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
params = {'state': 'open', 'per_page': 100}
pull_requests = get_all_items(url, params, limit)
return pull_requests
def generate_ods(issues, pull_requests, filename, people):
data = OrderedDict()
# Prepare issues data
issues_data = []
for n, issue in enumerate(issues):
issues_data.append(
[
issue['html_url'],
issue['title'],
issue['created_at'],
issue['user']['login'],
issue['assignee']['login'] if issue['assignee'] else 'None',
people[n % len(people)],
]
)
issues_headers = ['url', 'title', 'created_at', 'user', 'assignee', 'action']
issues_data.insert(0, issues_headers)
data.update({"Issues": issues_data})
# Prepare pull requests data
prs_data = []
for n, pr in enumerate(pull_requests):
prs_data.append(
[pr['html_url'], pr['title'], pr['created_at'], pr['user']['login'], pr['assignee']['login'] if pr['assignee'] else 'None', people[n % len(people)]]
)
prs_headers = ['url', 'title', 'created_at', 'user', 'assignee', 'action']
prs_data.insert(0, prs_headers)
data.update({"Pull Requests": prs_data})
# Save to ODS file
pyexcel.save_book_as(bookdict=data, dest_file_name=filename)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, help="minimum number of issues/PRs to pull [Pulls all by default]", default=None)
parser.add_argument("--out", type=str, help="output file name [awx_community-triage.ods]", default="awx_community-triage.ods")
parser.add_argument("--repository-url", type=str, help="repository url [https://github.com/ansible/awx]", default="https://github.com/ansible/awx")
parser.add_argument("--people", type=str, help="comma separated list of names to distribute the issues/PRs among [Alice,Bob]", default="Alice,Bob")
args = parser.parse_args()
limit = args.limit
output_file_name = args.out
repo_url = args.repository_url
people = str(args.people).split(",")
open_issues = get_open_issues(repo_url, limit)
open_pull_requests = get_open_pull_requests(repo_url, limit)
print(f"Open issues: {len(open_issues)}")
print(f"Open Pull Requests: {len(open_pull_requests)}")
generate_ods(open_issues, open_pull_requests, output_file_name, people)
print(f"Generated {output_file_name} with open issues and pull requests.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,2 @@
requests
pyexcel

View File

@@ -42,6 +42,7 @@ services:
DJANGO_SUPERUSER_PASSWORD: {{ admin_password }}
UWSGI_MOUNT_PATH: {{ ingress_path }}
DJANGO_COLORS: "${DJANGO_COLORS:-}"
DJANGO_SETTINGS_MODULE: "awx.settings"
{% if loop.index == 1 %}
RUN_MIGRATIONS: 1
{% endif %}