Merge branch 'test_cyberark' into cyberark-ccp-branding-webserviceid

This commit is contained in:
Cesar Francisco San Nicolas Martinez
2022-11-08 18:42:10 +01:00
committed by GitHub
62 changed files with 204125 additions and 197 deletions

View File

@@ -0,0 +1,24 @@
---
name: Feature branch deletion cleanup
on:
delete:
branches:
- feature_**
jobs:
push:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Delete API Schema
env:
AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }}
AWS_REGION: 'us-east-1'
run: |
ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
ansible localhost -c local -m aws_s3 \
-a "bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=delete permission=public-read"

View File

@@ -13,21 +13,13 @@ jobs:
packages: write packages: write
contents: read contents: read
steps: steps:
- name: Write PR body to a file
run: |
cat >> pr.body << __SOME_RANDOM_PR_EOF__
${{ github.event.pull_request.body }}
__SOME_RANDOM_PR_EOF__
- name: Display the received body for troubleshooting
run: cat pr.body
# We want to write these out individually just incase the options were joined on a single line
- name: Check for each of the lines - name: Check for each of the lines
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: | run: |
grep "Bug, Docs Fix or other nominal change" pr.body > Z echo $PR_BODY | grep "Bug, Docs Fix or other nominal change" > Z
grep "New or Enhanced Feature" pr.body > Y echo $PR_BODY | grep "New or Enhanced Feature" > Y
grep "Breaking Change" pr.body > X echo $PR_BODY | grep "Breaking Change" > X
exit 0 exit 0
# We exit 0 and set the shell to prevent the returns from the greps from failing this step # We exit 0 and set the shell to prevent the returns from the greps from failing this step
# See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference # See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference

View File

@@ -5,6 +5,7 @@ on:
branches: branches:
- devel - devel
- release_** - release_**
- feature_**
jobs: jobs:
push: push:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -85,6 +85,7 @@ clean: clean-ui clean-api clean-awxkit clean-dist
clean-api: clean-api:
rm -rf build $(NAME)-$(VERSION) *.egg-info rm -rf build $(NAME)-$(VERSION) *.egg-info
rm -rf .tox
find . -type f -regex ".*\.py[co]$$" -delete find . -type f -regex ".*\.py[co]$$" -delete
find . -type d -name "__pycache__" -delete find . -type d -name "__pycache__" -delete
rm -f awx/awx_test.sqlite3* rm -f awx/awx_test.sqlite3*
@@ -181,7 +182,7 @@ collectstatic:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ fi; \
mkdir -p awx/public/static && $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1 $(PYTHON) manage.py collectstatic --clear --noinput > /dev/null 2>&1
DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:* DEV_RELOAD_COMMAND ?= supervisorctl restart tower-processes:*
@@ -377,6 +378,8 @@ clean-ui:
rm -rf awx/ui/build rm -rf awx/ui/build
rm -rf awx/ui/src/locales/_build rm -rf awx/ui/src/locales/_build
rm -rf $(UI_BUILD_FLAG_FILE) rm -rf $(UI_BUILD_FLAG_FILE)
# the collectstatic command doesn't like it if this dir doesn't exist.
mkdir -p awx/ui/build/static
awx/ui/node_modules: awx/ui/node_modules:
NODE_OPTIONS=--max-old-space-size=6144 $(NPM_BIN) --prefix awx/ui --loglevel warn --force ci NODE_OPTIONS=--max-old-space-size=6144 $(NPM_BIN) --prefix awx/ui --loglevel warn --force ci
@@ -386,16 +389,14 @@ $(UI_BUILD_FLAG_FILE):
$(PYTHON) tools/scripts/compilemessages.py $(PYTHON) tools/scripts/compilemessages.py
$(NPM_BIN) --prefix awx/ui --loglevel warn run compile-strings $(NPM_BIN) --prefix awx/ui --loglevel warn run compile-strings
$(NPM_BIN) --prefix awx/ui --loglevel warn run build $(NPM_BIN) --prefix awx/ui --loglevel warn run build
mkdir -p awx/public/static/css mkdir -p /var/lib/awx/public/static/css
mkdir -p awx/public/static/js mkdir -p /var/lib/awx/public/static/js
mkdir -p awx/public/static/media mkdir -p /var/lib/awx/public/static/media
cp -r awx/ui/build/static/css/* awx/public/static/css cp -r awx/ui/build/static/css/* /var/lib/awx/public/static/css
cp -r awx/ui/build/static/js/* awx/public/static/js cp -r awx/ui/build/static/js/* /var/lib/awx/public/static/js
cp -r awx/ui/build/static/media/* awx/public/static/media cp -r awx/ui/build/static/media/* /var/lib/awx/public/static/media
touch $@ touch $@
ui-release: $(UI_BUILD_FLAG_FILE) ui-release: $(UI_BUILD_FLAG_FILE)
ui-devel: awx/ui/node_modules ui-devel: awx/ui/node_modules
@@ -453,6 +454,7 @@ COMPOSE_OPTS ?=
CONTROL_PLANE_NODE_COUNT ?= 1 CONTROL_PLANE_NODE_COUNT ?= 1
EXECUTION_NODE_COUNT ?= 2 EXECUTION_NODE_COUNT ?= 2
MINIKUBE_CONTAINER_GROUP ?= false MINIKUBE_CONTAINER_GROUP ?= false
MINIKUBE_SETUP ?= false # if false, run minikube separately
EXTRA_SOURCES_ANSIBLE_OPTS ?= EXTRA_SOURCES_ANSIBLE_OPTS ?=
ifneq ($(ADMIN_PASSWORD),) ifneq ($(ADMIN_PASSWORD),)
@@ -461,7 +463,7 @@ endif
docker-compose-sources: .git/hooks/pre-commit docker-compose-sources: .git/hooks/pre-commit
@if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\ @if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\
ansible-playbook -i tools/docker-compose/inventory tools/docker-compose-minikube/deploy.yml; \ ansible-playbook -i tools/docker-compose/inventory -e minikube_setup=$(MINIKUBE_SETUP) tools/docker-compose-minikube/deploy.yml; \
fi; fi;
ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/sources.yml \ ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/sources.yml \
@@ -635,4 +637,4 @@ help/generate:
} \ } \
} \ } \
{ lastLine = $$0 }' $(MAKEFILE_LIST) | sort -u { lastLine = $$0 }' $(MAKEFILE_LIST) | sort -u
@printf "\n" @printf "\n"

View File

@@ -2221,6 +2221,15 @@ class InventorySourceUpdateSerializer(InventorySourceSerializer):
class Meta: class Meta:
fields = ('can_update',) fields = ('can_update',)
def validate(self, attrs):
project = self.instance.source_project
if project:
failed_reason = project.get_reason_if_failed()
if failed_reason:
raise serializers.ValidationError(failed_reason)
return super(InventorySourceUpdateSerializer, self).validate(attrs)
class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSerializer): class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSerializer):
@@ -4272,17 +4281,10 @@ class JobLaunchSerializer(BaseSerializer):
# Basic validation - cannot run a playbook without a playbook # Basic validation - cannot run a playbook without a playbook
if not template.project: if not template.project:
errors['project'] = _("A project is required to run a job.") errors['project'] = _("A project is required to run a job.")
elif template.project.status in ('error', 'failed'): else:
errors['playbook'] = _("Missing a revision to run due to failed project update.") failure_reason = template.project.get_reason_if_failed()
if failure_reason:
latest_update = template.project.project_updates.last() errors['playbook'] = failure_reason
if latest_update is not None and latest_update.failed:
failed_validation_tasks = latest_update.project_update_events.filter(
event='runner_on_failed',
play="Perform project signature/checksum verification",
)
if failed_validation_tasks:
errors['playbook'] = _("Last project update failed due to signature validation failure.")
# cannot run a playbook without an inventory # cannot run a playbook without an inventory
if template.inventory and template.inventory.pending_deletion is True: if template.inventory and template.inventory.pending_deletion is True:
@@ -4952,7 +4954,7 @@ class InstanceSerializer(BaseSerializer):
res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk}) res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk})
res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk}) res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk})
if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor: if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor:
if obj.node_type != 'hop': if obj.node_type == 'execution':
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
return res return res

View File

@@ -1,5 +1,5 @@
Launch a Job Template: Launch a Job Template:
{% ifmeth GET %}
Make a GET request to this resource to determine if the job_template can be Make a GET request to this resource to determine if the job_template can be
launched and whether any passwords are required to launch the job_template. launched and whether any passwords are required to launch the job_template.
The response will include the following fields: The response will include the following fields:
@@ -29,8 +29,8 @@ The response will include the following fields:
* `inventory_needed_to_start`: Flag indicating the presence of an inventory * `inventory_needed_to_start`: Flag indicating the presence of an inventory
associated with the job template. If not then one should be supplied when associated with the job template. If not then one should be supplied when
launching the job (boolean, read-only) launching the job (boolean, read-only)
{% endifmeth %}
Make a POST request to this resource to launch the job_template. If any {% ifmeth POST %}Make a POST request to this resource to launch the job_template. If any
passwords, inventory, or extra variables (extra_vars) are required, they must passwords, inventory, or extra variables (extra_vars) are required, they must
be passed via POST data, with extra_vars given as a YAML or JSON string and be passed via POST data, with extra_vars given as a YAML or JSON string and
escaped parentheses. If the `inventory_needed_to_start` is `True` then the escaped parentheses. If the `inventory_needed_to_start` is `True` then the
@@ -41,3 +41,4 @@ are not provided, a 400 status code will be returned. If the job cannot be
launched, a 405 status code will be returned. If the provided credential or launched, a 405 status code will be returned. If the provided credential or
inventory are not allowed to be used by the user, then a 403 status code will inventory are not allowed to be used by the user, then a 403 status code will
be returned. be returned.
{% endifmeth %}

View File

@@ -392,8 +392,8 @@ class InstanceHealthCheck(GenericAPIView):
permission_classes = (IsSystemAdminOrAuditor,) permission_classes = (IsSystemAdminOrAuditor,)
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(node_type='execution')
# FIXME: For now, we don't have a good way of checking the health of a hop node. # FIXME: For now, we don't have a good way of checking the health of a hop node.
return super().get_queryset().exclude(node_type='hop')
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
@@ -413,9 +413,10 @@ class InstanceHealthCheck(GenericAPIView):
execution_node_health_check.apply_async([obj.hostname]) execution_node_health_check.apply_async([obj.hostname])
else: else:
from awx.main.tasks.system import cluster_node_health_check return Response(
{"error": f"Cannot run a health check on instances of type {obj.node_type}. Health checks can only be run on execution nodes."},
cluster_node_health_check.apply_async([obj.hostname], queue=obj.hostname) status=status.HTTP_400_BAD_REQUEST,
)
return Response({'msg': f"Health check is running for {obj.hostname}."}, status=status.HTTP_200_OK) return Response({'msg': f"Health check is running for {obj.hostname}."}, status=status.HTTP_200_OK)
@@ -2220,6 +2221,8 @@ class InventorySourceUpdateView(RetrieveAPIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
serializer = self.get_serializer(instance=obj, data=request.data)
serializer.is_valid(raise_exception=True)
if obj.can_update: if obj.can_update:
update = obj.update() update = obj.update()
if not update: if not update:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
from .plugin import CredentialPlugin, CertFiles, raise_for_status from .plugin import CredentialPlugin, CertFiles, raise_for_status
import base64
from urllib.parse import urljoin, quote from urllib.parse import urljoin, quote
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -61,7 +60,7 @@ def conjur_backend(**kwargs):
cacert = kwargs.get('cacert', None) cacert = kwargs.get('cacert', None)
auth_kwargs = { auth_kwargs = {
'headers': {'Content-Type': 'text/plain'}, 'headers': {'Content-Type': 'text/plain', 'Accept-Encoding': 'base64'},
'data': api_key, 'data': api_key,
'allow_redirects': False, 'allow_redirects': False,
} }
@@ -69,9 +68,9 @@ def conjur_backend(**kwargs):
with CertFiles(cacert) as cert: with CertFiles(cacert) as cert:
# https://www.conjur.org/api.html#authentication-authenticate-post # https://www.conjur.org/api.html#authentication-authenticate-post
auth_kwargs['verify'] = cert auth_kwargs['verify'] = cert
resp = requests.post(urljoin(url, '/'.join(['authn', account, username, 'authenticate'])), **auth_kwargs) resp = requests.post(urljoin(url, '/'.join(['api', 'authn', account, username, 'authenticate'])), **auth_kwargs)
raise_for_status(resp) raise_for_status(resp)
token = base64.b64encode(resp.content).decode('utf-8') token = resp.content.decode('utf-8')
lookup_kwargs = { lookup_kwargs = {
'headers': {'Authorization': 'Token token="{}"'.format(token)}, 'headers': {'Authorization': 'Token token="{}"'.format(token)},
@@ -79,9 +78,10 @@ def conjur_backend(**kwargs):
} }
# https://www.conjur.org/api.html#secrets-retrieve-a-secret-get # https://www.conjur.org/api.html#secrets-retrieve-a-secret-get
path = urljoin(url, '/'.join(['secrets', account, 'variable', secret_path])) path = urljoin(url, '/'.join(['api', 'secrets', account, 'variable', secret_path]))
if version: if version:
path = '?'.join([path, version]) ver = "version={}".format(version)
path = '?'.join([path, ver])
with CertFiles(cacert) as cert: with CertFiles(cacert) as cert:
lookup_kwargs['verify'] = cert lookup_kwargs['verify'] = cert
@@ -90,4 +90,4 @@ def conjur_backend(**kwargs):
return resp.text return resp.text
conjur_plugin = CredentialPlugin('CyberArk Conjur Secret Lookup', inputs=conjur_inputs, backend=conjur_backend) conjur_plugin = CredentialPlugin('CyberArk Conjur Secrets Manager Lookup', inputs=conjur_inputs, backend=conjur_backend)

View File

@@ -247,6 +247,19 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
return (number, step) return (number, step)
def get_sliced_hosts(self, host_queryset, slice_number, slice_count): def get_sliced_hosts(self, host_queryset, slice_number, slice_count):
"""
Returns a slice of Hosts given a slice number and total slice count, or
the original queryset if slicing is not requested.
NOTE: If slicing is performed, this will return a List[Host] with the
resulting slice. If slicing is not performed it will return the
original queryset (not evaluating it or forcing it to a list). This
puts the burden on the caller to check the resulting type. This is
non-ideal because it's easy to get wrong, but I think the only way
around it is to force the queryset which has memory implications for
large inventories.
"""
if slice_count > 1 and slice_number > 0: if slice_count > 1 and slice_number > 0:
offset = slice_number - 1 offset = slice_number - 1
host_queryset = host_queryset[offset::slice_count] host_queryset = host_queryset[offset::slice_count]

View File

@@ -15,6 +15,7 @@ from urllib.parse import urljoin
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models.query import QuerySet
# from django.core.cache import cache # from django.core.cache import cache
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
@@ -844,22 +845,30 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
def get_notification_friendly_name(self): def get_notification_friendly_name(self):
return "Job" return "Job"
def _get_inventory_hosts(self, only=['name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id']): def _get_inventory_hosts(self, only=('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id'), **filters):
"""Return value is an iterable for the relevant hosts for this job"""
if not self.inventory: if not self.inventory:
return [] return []
host_queryset = self.inventory.hosts.only(*only) host_queryset = self.inventory.hosts.only(*only)
return self.inventory.get_sliced_hosts(host_queryset, self.job_slice_number, self.job_slice_count) if filters:
host_queryset = host_queryset.filter(**filters)
host_queryset = self.inventory.get_sliced_hosts(host_queryset, self.job_slice_number, self.job_slice_count)
if isinstance(host_queryset, QuerySet):
return host_queryset.iterator()
return host_queryset
def start_job_fact_cache(self, destination, modification_times, timeout=None): def start_job_fact_cache(self, destination, modification_times, timeout=None):
self.log_lifecycle("start_job_fact_cache") self.log_lifecycle("start_job_fact_cache")
os.makedirs(destination, mode=0o700) os.makedirs(destination, mode=0o700)
hosts = self._get_inventory_hosts()
if timeout is None: if timeout is None:
timeout = settings.ANSIBLE_FACT_CACHE_TIMEOUT timeout = settings.ANSIBLE_FACT_CACHE_TIMEOUT
if timeout > 0: if timeout > 0:
# exclude hosts with fact data older than `settings.ANSIBLE_FACT_CACHE_TIMEOUT seconds` # exclude hosts with fact data older than `settings.ANSIBLE_FACT_CACHE_TIMEOUT seconds`
timeout = now() - datetime.timedelta(seconds=timeout) timeout = now() - datetime.timedelta(seconds=timeout)
hosts = hosts.filter(ansible_facts_modified__gte=timeout) hosts = self._get_inventory_hosts(ansible_facts_modified__gte=timeout)
else:
hosts = self._get_inventory_hosts()
for host in hosts: for host in hosts:
filepath = os.sep.join(map(str, [destination, host.name])) filepath = os.sep.join(map(str, [destination, host.name]))
if not os.path.realpath(filepath).startswith(destination): if not os.path.realpath(filepath).startswith(destination):

View File

@@ -471,6 +471,29 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
def get_absolute_url(self, request=None): def get_absolute_url(self, request=None):
return reverse('api:project_detail', kwargs={'pk': self.pk}, request=request) return reverse('api:project_detail', kwargs={'pk': self.pk}, request=request)
def get_reason_if_failed(self):
"""
If the project is in a failed or errored state, return a human-readable
error message explaining why. Otherwise return None.
This is used during validation in the serializer and also by
RunProjectUpdate/RunInventoryUpdate.
"""
if self.status not in ('error', 'failed'):
return None
latest_update = self.project_updates.last()
if latest_update is not None and latest_update.failed:
failed_validation_tasks = latest_update.project_update_events.filter(
event='runner_on_failed',
play="Perform project signature/checksum verification",
)
if failed_validation_tasks:
return _("Last project update failed due to signature validation failure.")
return _("Missing a revision to run due to failed project update.")
''' '''
RelatedJobsMixin RelatedJobsMixin
''' '''

View File

@@ -5,9 +5,6 @@ import json
import logging import logging
import requests import requests
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
from awx.main.notifications.base import AWXBaseEmailBackend from awx.main.notifications.base import AWXBaseEmailBackend
from awx.main.utils import get_awx_http_client_headers from awx.main.utils import get_awx_http_client_headers
from awx.main.notifications.custom_notification_base import CustomNotificationBase from awx.main.notifications.custom_notification_base import CustomNotificationBase
@@ -17,6 +14,8 @@ logger = logging.getLogger('awx.main.notifications.webhook_backend')
class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase): class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
MAX_RETRIES = 5
init_parameters = { init_parameters = {
"url": {"label": "Target URL", "type": "string"}, "url": {"label": "Target URL", "type": "string"},
"http_method": {"label": "HTTP Method", "type": "string", "default": "POST"}, "http_method": {"label": "HTTP Method", "type": "string", "default": "POST"},
@@ -64,20 +63,67 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
if self.http_method.lower() not in ['put', 'post']: if self.http_method.lower() not in ['put', 'post']:
raise ValueError("HTTP method must be either 'POST' or 'PUT'.") raise ValueError("HTTP method must be either 'POST' or 'PUT'.")
chosen_method = getattr(requests, self.http_method.lower(), None) chosen_method = getattr(requests, self.http_method.lower(), None)
for m in messages: for m in messages:
auth = None auth = None
if self.username or self.password: if self.username or self.password:
auth = (self.username, self.password) auth = (self.username, self.password)
r = chosen_method(
"{}".format(m.recipients()[0]), # the constructor for EmailMessage - https://docs.djangoproject.com/en/4.1/_modules/django/core/mail/message will turn an empty dictionary to an empty string
auth=auth, # sometimes an empty dict is intentional and we added this conditional to enforce that
data=json.dumps(m.body, ensure_ascii=False).encode('utf-8'), if not m.body:
headers=dict(list(get_awx_http_client_headers().items()) + list((self.headers or {}).items())), m.body = {}
verify=(not self.disable_ssl_verification),
) url = str(m.recipients()[0])
if r.status_code >= 400: data = json.dumps(m.body, ensure_ascii=False).encode('utf-8')
logger.error(smart_str(_("Error sending notification webhook: {}").format(r.status_code))) headers = {**(get_awx_http_client_headers()), **(self.headers or {})}
err = None
for retries in range(self.MAX_RETRIES):
# Sometimes we hit redirect URLs. We must account for this. We still extract the redirect URL from the response headers and try again. Max retires == 5
resp = chosen_method(
url=url,
auth=auth,
data=data,
headers=headers,
verify=(not self.disable_ssl_verification),
allow_redirects=False, # override default behaviour for redirects
)
# either success or error reached if this conditional fires
if resp.status_code not in [301, 307]:
break
# we've hit a redirect. extract the redirect URL out of the first response header and try again
logger.warning(
f"Received a {resp.status_code} from {url}, trying to reach redirect url {resp.headers.get('Location', None)}; attempt #{retries+1}"
)
# take the first redirect URL in the response header and try that
url = resp.headers.get("Location", None)
if url is None:
err = f"Webhook notification received redirect to a blank URL from {url}. Response headers={resp.headers}"
break
else:
# no break condition in the loop encountered; therefore we have hit the maximum number of retries
err = f"Webhook notification max number of retries [{self.MAX_RETRIES}] exceeded. Failed to send webhook notification to {url}"
if resp.status_code >= 400:
err = f"Error sending webhook notification: {resp.status_code}"
# log error message
if err:
logger.error(err)
if not self.fail_silently: if not self.fail_silently:
raise Exception(smart_str(_("Error sending notification webhook: {}").format(r.status_code))) raise Exception(err)
sent_messages += 1
# no errors were encountered therefore we successfully sent off the notification webhook
if resp.status_code in range(200, 299):
logger.debug(f"Notification webhook successfully sent to {url}. Received {resp.status_code}")
sent_messages += 1
return sent_messages return sent_messages

View File

@@ -3,6 +3,8 @@
from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed
from taggit.managers import TaggableManager
class ActivityStreamRegistrar(object): class ActivityStreamRegistrar(object):
def __init__(self): def __init__(self):
@@ -19,6 +21,8 @@ class ActivityStreamRegistrar(object):
pre_delete.connect(activity_stream_delete, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_delete") pre_delete.connect(activity_stream_delete, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_delete")
for m2mfield in model._meta.many_to_many: for m2mfield in model._meta.many_to_many:
if isinstance(m2mfield, TaggableManager):
continue # Special case for taggit app
try: try:
m2m_attr = getattr(model, m2mfield.name) m2m_attr = getattr(model, m2mfield.name)
m2m_changed.connect( m2m_changed.connect(

View File

@@ -2,8 +2,6 @@ import json
import time import time
import logging import logging
from collections import deque from collections import deque
import os
import stat
# Django # Django
from django.conf import settings from django.conf import settings
@@ -206,21 +204,6 @@ class RunnerCallback:
self.instance = self.update_model(self.instance.pk, job_args=json.dumps(runner_config.command), job_cwd=runner_config.cwd, job_env=job_env) self.instance = self.update_model(self.instance.pk, job_args=json.dumps(runner_config.command), job_cwd=runner_config.cwd, job_env=job_env)
# We opened a connection just for that save, close it here now # We opened a connection just for that save, close it here now
connections.close_all() connections.close_all()
elif status_data['status'] == 'failed':
# For encrypted ssh_key_data, ansible-runner worker will open and write the
# ssh_key_data to a named pipe. Then, once the podman container starts, ssh-agent will
# read from this named pipe so that the key can be used in ansible-playbook.
# Once the podman container exits, the named pipe is deleted.
# However, if the podman container fails to start in the first place, e.g. the image
# name is incorrect, then this pipe is not cleaned up. Eventually ansible-runner
# processor will attempt to write artifacts to the private data dir via unstream_dir, requiring
# that it open this named pipe. This leads to a hang. Thus, before any artifacts
# are written by the processor, it's important to remove this ssh_key_data pipe.
private_data_dir = self.instance.job_env.get('AWX_PRIVATE_DATA_DIR', None)
if private_data_dir:
key_data_file = os.path.join(private_data_dir, 'artifacts', str(self.instance.id), 'ssh_key_data')
if os.path.exists(key_data_file) and stat.S_ISFIFO(os.stat(key_data_file).st_mode):
os.remove(key_data_file)
elif status_data['status'] == 'error': elif status_data['status'] == 'error':
result_traceback = status_data.get('result_traceback', None) result_traceback = status_data.get('result_traceback', None)
if result_traceback: if result_traceback:

View File

@@ -767,6 +767,10 @@ class SourceControlMixin(BaseTask):
try: try:
original_branch = None original_branch = None
failed_reason = project.get_reason_if_failed()
if failed_reason:
self.update_model(self.instance.pk, status='failed', job_explanation=failed_reason)
raise RuntimeError(failed_reason)
project_path = project.get_project_path(check_if_exists=False) project_path = project.get_project_path(check_if_exists=False)
if project.scm_type == 'git' and (scm_branch and scm_branch != project.scm_branch): if project.scm_type == 'git' and (scm_branch and scm_branch != project.scm_branch):
if os.path.exists(project_path): if os.path.exists(project_path):
@@ -1056,10 +1060,6 @@ class RunJob(SourceControlMixin, BaseTask):
error = _('Job could not start because no Execution Environment could be found.') error = _('Job could not start because no Execution Environment could be found.')
self.update_model(job.pk, status='error', job_explanation=error) self.update_model(job.pk, status='error', job_explanation=error)
raise RuntimeError(error) raise RuntimeError(error)
elif job.project.status in ('error', 'failed'):
msg = _('The project revision for this job template is unknown due to a failed update.')
job = self.update_model(job.pk, status='failed', job_explanation=msg)
raise RuntimeError(msg)
if job.inventory.kind == 'smart': if job.inventory.kind == 'smart':
# cache smart inventory memberships so that the host_filter query is not # cache smart inventory memberships so that the host_filter query is not

View File

@@ -208,7 +208,10 @@ def run_until_complete(node, timing_data=None, **kwargs):
if state_name.lower() == 'failed': if state_name.lower() == 'failed':
work_detail = status.get('Detail', '') work_detail = status.get('Detail', '')
if work_detail: if work_detail:
raise RemoteJobError(f'Receptor error from {node}, detail:\n{work_detail}') if stdout:
raise RemoteJobError(f'Receptor error from {node}, detail:\n{work_detail}\nstdout:\n{stdout}')
else:
raise RemoteJobError(f'Receptor error from {node}, detail:\n{work_detail}')
else: else:
raise RemoteJobError(f'Unknown ansible-runner error on node {node}, stdout:\n{stdout}') raise RemoteJobError(f'Unknown ansible-runner error on node {node}, stdout:\n{stdout}')

View File

@@ -7,7 +7,7 @@ from awx.main.models.ha import Instance
from django.test.utils import override_settings from django.test.utils import override_settings
INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, memory=36000000000, cpu_capacity=6, mem_capacity=42) INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, node_type='execution', memory=36000000000, cpu_capacity=6, mem_capacity=42)
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -75,6 +75,7 @@ def test_encrypted_subfields(get, post, user, organization):
url = reverse('api:notification_template_detail', kwargs={'pk': response.data['id']}) url = reverse('api:notification_template_detail', kwargs={'pk': response.data['id']})
response = get(url, u) response = get(url, u)
assert response.data['notification_configuration']['account_token'] == "$encrypted$" assert response.data['notification_configuration']['account_token'] == "$encrypted$"
with mock.patch.object(notification_template_actual.notification_class, "send_messages", assert_send): with mock.patch.object(notification_template_actual.notification_class, "send_messages", assert_send):
notification_template_actual.send("Test", {'body': "Test"}) notification_template_actual.send("Test", {'body': "Test"})
@@ -175,3 +176,46 @@ def test_custom_environment_injection(post, user, organization):
fake_send.side_effect = _send_side_effect fake_send.side_effect = _send_side_effect
template.send('subject', 'message') template.send('subject', 'message')
def mock_post(*args, **kwargs):
class MockGoodResponse:
def __init__(self):
self.status_code = 200
class MockRedirectResponse:
def __init__(self):
self.status_code = 301
self.headers = {"Location": "http://goodendpoint"}
if kwargs['url'] == "http://goodendpoint":
return MockGoodResponse()
else:
return MockRedirectResponse()
@pytest.mark.django_db
@mock.patch('requests.post', side_effect=mock_post)
def test_webhook_notification_pointed_to_a_redirect_launch_endpoint(post, admin, organization):
n1 = NotificationTemplate.objects.create(
name="test-webhook",
description="test webhook",
organization=organization,
notification_type="webhook",
notification_configuration=dict(
url="http://some.fake.url",
disable_ssl_verification=True,
http_method="POST",
headers={
"Content-Type": "application/json",
},
username=admin.username,
password=admin.password,
),
messages={
"success": {"message": "", "body": "{}"},
},
)
assert n1.send("", n1.messages.get("success").get("body")) == 1

View File

@@ -27,11 +27,12 @@ def test_send_messages_as_POST():
] ]
) )
requests_mock.post.assert_called_once_with( requests_mock.post.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=None, auth=None,
data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'},
verify=True, verify=True,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1
@@ -57,11 +58,12 @@ def test_send_messages_as_PUT():
] ]
) )
requests_mock.put.assert_called_once_with( requests_mock.put.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=None, auth=None,
data=json.dumps({'text': 'test body 2'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body 2'}, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'},
verify=True, verify=True,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1
@@ -87,11 +89,12 @@ def test_send_messages_with_username():
] ]
) )
requests_mock.post.assert_called_once_with( requests_mock.post.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=('userstring', None), auth=('userstring', None),
data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'},
verify=True, verify=True,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1
@@ -117,11 +120,12 @@ def test_send_messages_with_password():
] ]
) )
requests_mock.post.assert_called_once_with( requests_mock.post.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=(None, 'passwordstring'), auth=(None, 'passwordstring'),
data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'},
verify=True, verify=True,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1
@@ -147,11 +151,12 @@ def test_send_messages_with_username_and_password():
] ]
) )
requests_mock.post.assert_called_once_with( requests_mock.post.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=('userstring', 'passwordstring'), auth=('userstring', 'passwordstring'),
data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'},
verify=True, verify=True,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1
@@ -177,11 +182,12 @@ def test_send_messages_with_no_verify_ssl():
] ]
) )
requests_mock.post.assert_called_once_with( requests_mock.post.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=None, auth=None,
data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'},
verify=False, verify=False,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1
@@ -207,7 +213,7 @@ def test_send_messages_with_additional_headers():
] ]
) )
requests_mock.post.assert_called_once_with( requests_mock.post.assert_called_once_with(
'http://example.com', url='http://example.com',
auth=None, auth=None,
data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'),
headers={ headers={
@@ -217,5 +223,6 @@ def test_send_messages_with_additional_headers():
'X-Test-Header2': 'test-content-2', 'X-Test-Header2': 'test-content-2',
}, },
verify=True, verify=True,
allow_redirects=False,
) )
assert sent_messages == 1 assert sent_messages == 1

View File

@@ -110,7 +110,7 @@ if settings.COLOR_LOGS is True:
# logs rendered with cyan text # logs rendered with cyan text
previous_level_map = self.level_map.copy() previous_level_map = self.level_map.copy()
if record.name == "awx.analytics.job_lifecycle": if record.name == "awx.analytics.job_lifecycle":
self.level_map[logging.DEBUG] = (None, 'cyan', True) self.level_map[logging.INFO] = (None, 'cyan', True)
msg = super(ColorHandler, self).colorize(line, record) msg = super(ColorHandler, self).colorize(line, record)
self.level_map = previous_level_map self.level_map = previous_level_map
return msg return msg

View File

@@ -101,7 +101,7 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'ui', 'build', 'static'), os.path.join(BASE_DIR, 'static')) STATICFILES_DIRS = [os.path.join(BASE_DIR, 'ui', 'build', 'static'), os.path.join(BASE_DIR, 'static')]
# Absolute filesystem path to the directory where static file are collected via # Absolute filesystem path to the directory where static file are collected via
# the collectstatic command. # the collectstatic command.
@@ -254,6 +254,14 @@ START_TASK_LIMIT = 100
TASK_MANAGER_TIMEOUT = 300 TASK_MANAGER_TIMEOUT = 300
TASK_MANAGER_TIMEOUT_GRACE_PERIOD = 60 TASK_MANAGER_TIMEOUT_GRACE_PERIOD = 60
# Number of seconds _in addition to_ the task manager timeout a job can stay
# in waiting without being reaped
JOB_WAITING_GRACE_PERIOD = 60
# Number of seconds after a container group job finished time to wait
# before the awx_k8s_reaper task will tear down the pods
K8S_POD_REAPER_GRACE_PERIOD = 60
# Disallow sending session cookies over insecure connections # Disallow sending session cookies over insecure connections
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
@@ -1004,16 +1012,5 @@ DEFAULT_CONTAINER_RUN_OPTIONS = ['--network', 'slirp4netns:enable_ipv6=true']
# Mount exposed paths as hostPath resource in k8s/ocp # Mount exposed paths as hostPath resource in k8s/ocp
AWX_MOUNT_ISOLATED_PATHS_ON_K8S = False AWX_MOUNT_ISOLATED_PATHS_ON_K8S = False
# Time out task managers if they take longer than this many seconds
TASK_MANAGER_TIMEOUT = 300
# Number of seconds _in addition to_ the task manager timeout a job can stay
# in waiting without being reaped
JOB_WAITING_GRACE_PERIOD = 60
# Number of seconds after a container group job finished time to wait
# before the awx_k8s_reaper task will tear down the pods
K8S_POD_REAPER_GRACE_PERIOD = 60
# This is overridden downstream via /etc/tower/conf.d/cluster_host_id.py # This is overridden downstream via /etc/tower/conf.d/cluster_host_id.py
CLUSTER_HOST_ID = socket.gethostname() CLUSTER_HOST_ID = socket.gethostname()

View File

@@ -7,9 +7,9 @@
"name": "ui", "name": "ui",
"dependencies": { "dependencies": {
"@lingui/react": "3.14.0", "@lingui/react": "3.14.0",
"@patternfly/patternfly": "4.210.2", "@patternfly/patternfly": "4.217.1",
"@patternfly/react-core": "^4.239.0", "@patternfly/react-core": "^4.250.1",
"@patternfly/react-icons": "4.90.0", "@patternfly/react-icons": "4.92.10",
"@patternfly/react-table": "4.108.0", "@patternfly/react-table": "4.108.0",
"ace-builds": "^1.10.1", "ace-builds": "^1.10.1",
"ansi-to-html": "0.7.2", "ansi-to-html": "0.7.2",
@@ -3747,26 +3747,26 @@
"dev": true "dev": true
}, },
"node_modules/@patternfly/patternfly": { "node_modules/@patternfly/patternfly": {
"version": "4.210.2", "version": "4.217.1",
"resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.210.2.tgz", "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.217.1.tgz",
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw==" "integrity": "sha512-uN7JgfQsyR16YHkuGRCTIcBcnyKIqKjGkB2SGk9x1XXH3yYGenL83kpAavX9Xtozqp17KppOlybJuzcKvZMrgw=="
}, },
"node_modules/@patternfly/react-core": { "node_modules/@patternfly/react-core": {
"version": "4.239.0", "version": "4.250.1",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.250.1.tgz",
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==", "integrity": "sha512-vAOZPQdZzYXl/vkHnHMIt1eC3nrPDdsuuErPatkNPwmSvilXuXmWP5wxoJ36FbSNRRURkprFwx52zMmWS3iHJA==",
"dependencies": { "dependencies": {
"@patternfly/react-icons": "^4.90.0", "@patternfly/react-icons": "^4.92.6",
"@patternfly/react-styles": "^4.89.0", "@patternfly/react-styles": "^4.91.6",
"@patternfly/react-tokens": "^4.91.0", "@patternfly/react-tokens": "^4.93.6",
"focus-trap": "6.9.2", "focus-trap": "6.9.2",
"react-dropzone": "9.0.0", "react-dropzone": "9.0.0",
"tippy.js": "5.1.2", "tippy.js": "5.1.2",
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0", "react": "^16.8 || ^17 || ^18",
"react-dom": "^16.8.0 || ^17.0.0" "react-dom": "^16.8 || ^17 || ^18"
} }
}, },
"node_modules/@patternfly/react-core/node_modules/tslib": { "node_modules/@patternfly/react-core/node_modules/tslib": {
@@ -3775,18 +3775,18 @@
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}, },
"node_modules/@patternfly/react-icons": { "node_modules/@patternfly/react-icons": {
"version": "4.90.0", "version": "4.92.10",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.92.10.tgz",
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==", "integrity": "sha512-vwCy7b+OyyuvLDSLqLUG2DkJZgMDogjld8tJTdAaG8HiEhC1sJPZac+5wD7AuS3ym/sQolS4vYtNiVDnMEORxA==",
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0", "react": "^16.8 || ^17 || ^18",
"react-dom": "^16.8.0 || ^17.0.0" "react-dom": "^16.8 || ^17 || ^18"
} }
}, },
"node_modules/@patternfly/react-styles": { "node_modules/@patternfly/react-styles": {
"version": "4.89.0", "version": "4.91.10",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.91.10.tgz",
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ==" "integrity": "sha512-fAG4Vjp63ohiR92F4e/Gkw5q1DSSckHKqdnEF75KUpSSBORzYP0EKMpupSd6ItpQFJw3iWs3MJi3/KIAAfU1Jw=="
}, },
"node_modules/@patternfly/react-table": { "node_modules/@patternfly/react-table": {
"version": "4.108.0", "version": "4.108.0",
@@ -3811,9 +3811,9 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
}, },
"node_modules/@patternfly/react-tokens": { "node_modules/@patternfly/react-tokens": {
"version": "4.91.0", "version": "4.93.10",
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.93.10.tgz",
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw==" "integrity": "sha512-F+j1irDc9M6zvY6qNtDryhbpnHz3R8ymHRdGelNHQzPTIK88YSWEnT1c9iUI+uM/iuZol7sJmO5STtg2aPIDRQ=="
}, },
"node_modules/@pmmmwh/react-refresh-webpack-plugin": { "node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.4", "version": "0.5.4",
@@ -25089,18 +25089,18 @@
"dev": true "dev": true
}, },
"@patternfly/patternfly": { "@patternfly/patternfly": {
"version": "4.210.2", "version": "4.217.1",
"resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.210.2.tgz", "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.217.1.tgz",
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw==" "integrity": "sha512-uN7JgfQsyR16YHkuGRCTIcBcnyKIqKjGkB2SGk9x1XXH3yYGenL83kpAavX9Xtozqp17KppOlybJuzcKvZMrgw=="
}, },
"@patternfly/react-core": { "@patternfly/react-core": {
"version": "4.239.0", "version": "4.250.1",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.250.1.tgz",
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==", "integrity": "sha512-vAOZPQdZzYXl/vkHnHMIt1eC3nrPDdsuuErPatkNPwmSvilXuXmWP5wxoJ36FbSNRRURkprFwx52zMmWS3iHJA==",
"requires": { "requires": {
"@patternfly/react-icons": "^4.90.0", "@patternfly/react-icons": "^4.92.6",
"@patternfly/react-styles": "^4.89.0", "@patternfly/react-styles": "^4.91.6",
"@patternfly/react-tokens": "^4.91.0", "@patternfly/react-tokens": "^4.93.6",
"focus-trap": "6.9.2", "focus-trap": "6.9.2",
"react-dropzone": "9.0.0", "react-dropzone": "9.0.0",
"tippy.js": "5.1.2", "tippy.js": "5.1.2",
@@ -25115,15 +25115,15 @@
} }
}, },
"@patternfly/react-icons": { "@patternfly/react-icons": {
"version": "4.90.0", "version": "4.92.10",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.92.10.tgz",
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==", "integrity": "sha512-vwCy7b+OyyuvLDSLqLUG2DkJZgMDogjld8tJTdAaG8HiEhC1sJPZac+5wD7AuS3ym/sQolS4vYtNiVDnMEORxA==",
"requires": {} "requires": {}
}, },
"@patternfly/react-styles": { "@patternfly/react-styles": {
"version": "4.89.0", "version": "4.91.10",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.91.10.tgz",
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ==" "integrity": "sha512-fAG4Vjp63ohiR92F4e/Gkw5q1DSSckHKqdnEF75KUpSSBORzYP0EKMpupSd6ItpQFJw3iWs3MJi3/KIAAfU1Jw=="
}, },
"@patternfly/react-table": { "@patternfly/react-table": {
"version": "4.108.0", "version": "4.108.0",
@@ -25146,9 +25146,9 @@
} }
}, },
"@patternfly/react-tokens": { "@patternfly/react-tokens": {
"version": "4.91.0", "version": "4.93.10",
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.93.10.tgz",
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw==" "integrity": "sha512-F+j1irDc9M6zvY6qNtDryhbpnHz3R8ymHRdGelNHQzPTIK88YSWEnT1c9iUI+uM/iuZol7sJmO5STtg2aPIDRQ=="
}, },
"@pmmmwh/react-refresh-webpack-plugin": { "@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.4", "version": "0.5.4",

View File

@@ -7,9 +7,9 @@
}, },
"dependencies": { "dependencies": {
"@lingui/react": "3.14.0", "@lingui/react": "3.14.0",
"@patternfly/patternfly": "4.210.2", "@patternfly/patternfly": "4.217.1",
"@patternfly/react-core": "^4.239.0", "@patternfly/react-core": "^4.250.1",
"@patternfly/react-icons": "4.90.0", "@patternfly/react-icons": "4.92.10",
"@patternfly/react-table": "4.108.0", "@patternfly/react-table": "4.108.0",
"ace-builds": "^1.10.1", "ace-builds": "^1.10.1",
"ansi-to-html": "0.7.2", "ansi-to-html": "0.7.2",

View File

@@ -416,8 +416,14 @@ function ScheduleForm({
if (options.end === 'onDate') { if (options.end === 'onDate') {
if ( if (
DateTime.fromISO(values.startDate) >= DateTime.fromFormat(
DateTime.fromISO(options.endDate) `${values.startDate} ${values.startTime}`,
'yyyy-LL-dd h:mm a'
).toMillis() >=
DateTime.fromFormat(
`${options.endDate} ${options.endTime}`,
'yyyy-LL-dd h:mm a'
).toMillis()
) { ) {
freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`; freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
} }

View File

@@ -900,6 +900,36 @@ describe('<ScheduleForm />', () => {
); );
}); });
test('should create schedule with the same start and end date provided that the end date is at a later time', async () => {
const today = DateTime.now().toFormat('yyyy-LL-dd');
const laterTime = DateTime.now().plus({ hours: 1 }).toFormat('h:mm a');
await act(async () => {
wrapper.find('DatePicker[aria-label="End date"]').prop('onChange')(
today,
new Date(today)
);
});
wrapper.update();
expect(
wrapper
.find('FormGroup[data-cy="schedule-End date/time"]')
.prop('helperTextInvalid')
).toBe(
'Please select an end date/time that comes after the start date/time.'
);
await act(async () => {
wrapper.find('TimePicker[aria-label="End time"]').prop('onChange')(
laterTime
);
});
wrapper.update();
expect(
wrapper
.find('FormGroup[data-cy="schedule-End date/time"]')
.prop('helperTextInvalid')
).toBe(undefined);
});
test('error shown when on day number is not between 1 and 31', async () => { test('error shown when on day number is not between 1 and 31', async () => {
await act(async () => { await act(async () => {
wrapper.find('FrequencySelect#schedule-frequency').invoke('onChange')([ wrapper.find('FrequencySelect#schedule-frequency').invoke('onChange')([

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -282,7 +282,7 @@ const mockInputSources = {
summary_fields: { summary_fields: {
source_credential: { source_credential: {
id: 20, id: 20,
name: 'CyberArk Conjur Secret Lookup', name: 'CyberArk Conjur Secrets Manager Lookup',
description: '', description: '',
kind: 'conjur', kind: 'conjur',
cloud: false, cloud: false,
@@ -301,7 +301,7 @@ const mockInputSources = {
summary_fields: { summary_fields: {
source_credential: { source_credential: {
id: 20, id: 20,
name: 'CyberArk Conjur Secret Lookup', name: 'CyberArk Conjur Secrets Manager Lookup',
description: '', description: '',
kind: 'conjur', kind: 'conjur',
cloud: false, cloud: false,

View File

@@ -36,14 +36,14 @@ const mockCredentialTypeDetail = {
url: '/api/v2/credential_types/20/', url: '/api/v2/credential_types/20/',
related: { related: {
named_url: named_url:
'/api/v2/credential_types/CyberArk Conjur Secret Lookup+external/', '/api/v2/credential_types/CyberArk Conjur Secrets Manager Lookup+external/',
credentials: '/api/v2/credential_types/20/credentials/', credentials: '/api/v2/credential_types/20/credentials/',
activity_stream: '/api/v2/credential_types/20/activity_stream/', activity_stream: '/api/v2/credential_types/20/activity_stream/',
}, },
summary_fields: { user_capabilities: { edit: false, delete: false } }, summary_fields: { user_capabilities: { edit: false, delete: false } },
created: '2020-05-18T21:53:35.398260Z', created: '2020-05-18T21:53:35.398260Z',
modified: '2020-05-18T21:54:05.451444Z', modified: '2020-05-18T21:54:05.451444Z',
name: 'CyberArk Conjur Secret Lookup', name: 'CyberArk Conjur Secrets Manager Lookup',
description: '', description: '',
kind: 'external', kind: 'external',
namespace: 'conjur', namespace: 'conjur',

View File

@@ -546,7 +546,7 @@
}, },
"created": "2020-05-18T21:53:35.398260Z", "created": "2020-05-18T21:53:35.398260Z",
"modified": "2020-05-18T21:54:05.451444Z", "modified": "2020-05-18T21:54:05.451444Z",
"name": "CyberArk Conjur Secret Lookup", "name": "CyberArk Conjur Secrets Manager Lookup",
"description": "", "description": "",
"kind": "external", "kind": "external",
"namespace": "conjur", "namespace": "conjur",

View File

@@ -3,7 +3,7 @@
"type": "credential", "type": "credential",
"url": "/api/v2/credentials/1/", "url": "/api/v2/credentials/1/",
"related": { "related": {
"named_url": "/api/v2/credentials/CyberArk Conjur Secret Lookup++CyberArk Conjur Secret Lookup+external++/", "named_url": "/api/v2/credentials/CyberArk Conjur Secrets Manager Lookup+external++/",
"created_by": "/api/v2/users/1/", "created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/", "modified_by": "/api/v2/users/1/",
"activity_stream": "/api/v2/credentials/1/activity_stream/", "activity_stream": "/api/v2/credentials/1/activity_stream/",
@@ -19,7 +19,7 @@
"summary_fields": { "summary_fields": {
"credential_type": { "credential_type": {
"id": 20, "id": 20,
"name": "CyberArk Conjur Secret Lookup", "name": "CyberArk Conjur Secrets Manager Lookup",
"description": "" "description": ""
}, },
"created_by": { "created_by": {
@@ -69,7 +69,7 @@
}, },
"created": "2020-05-19T12:51:36.956029Z", "created": "2020-05-19T12:51:36.956029Z",
"modified": "2020-05-19T12:51:36.956086Z", "modified": "2020-05-19T12:51:36.956086Z",
"name": "CyberArk Conjur Secret Lookup", "name": "CyberArk Conjur Secrets Manager Lookup",
"description": "", "description": "",
"organization": null, "organization": null,
"credential_type": 20, "credential_type": 20,

View File

@@ -62,7 +62,7 @@ const getStdOutValue = (hostEvent) => {
) { ) {
stdOut = res.results.join('\n'); stdOut = res.results.join('\n');
} else if (res?.stdout) { } else if (res?.stdout) {
stdOut = res.stdout; stdOut = Array.isArray(res.stdout) ? res.stdout.join(' ') : res.stdout;
} }
return stdOut; return stdOut;
}; };

View File

@@ -114,7 +114,12 @@ def main():
# Update the project # Update the project
result = module.post_endpoint(project['related']['update']) result = module.post_endpoint(project['related']['update'])
if result['status_code'] != 202: if result['status_code'] == 405:
module.fail_json(
msg="Unable to trigger a project update because the project scm_type ({0}) does not support it.".format(project['scm_type']),
response=result
)
elif result['status_code'] != 202:
module.fail_json(msg="Failed to update project, see response for details", response=result) module.fail_json(msg="Failed to update project, see response for details", response=result)
module.json_output['changed'] = True module.json_output['changed'] = True

View File

@@ -275,7 +275,13 @@ class ApiV2(base.Base):
# When creating a project, we need to wait for its # When creating a project, we need to wait for its
# first project update to finish so that associated # first project update to finish so that associated
# JTs have valid options for playbook names # JTs have valid options for playbook names
_page.wait_until_completed() try:
_page.wait_until_completed(timeout=300)
except AssertionError:
# If the project update times out, try to
# carry on in the hopes that it will
# finish before it is needed.
pass
else: else:
# If we are an existing project and our scm_tpye is not changing don't try and import the local_path setting # If we are an existing project and our scm_tpye is not changing don't try and import the local_path setting
if asset['natural_key']['type'] == 'project' and 'local_path' in post_data and _page['scm_type'] == post_data['scm_type']: if asset['natural_key']['type'] == 'project' and 'local_path' in post_data and _page['scm_type'] == post_data['scm_type']:

View File

@@ -1,6 +1,7 @@
from contextlib import suppress from contextlib import suppress
from awxkit.api.pages import SystemJobTemplate from awxkit.api.pages import JobTemplate, SystemJobTemplate, Project, InventorySource
from awxkit.api.pages.workflow_job_templates import WorkflowJobTemplate
from awxkit.api.mixins import HasCreate from awxkit.api.mixins import HasCreate
from awxkit.api.resources import resources from awxkit.api.resources import resources
from awxkit.config import config from awxkit.config import config
@@ -11,7 +12,7 @@ from . import base
class Schedule(HasCreate, base.Base): class Schedule(HasCreate, base.Base):
dependencies = [SystemJobTemplate] dependencies = [JobTemplate, SystemJobTemplate, Project, InventorySource, WorkflowJobTemplate]
NATURAL_KEY = ('unified_job_template', 'name') NATURAL_KEY = ('unified_job_template', 'name')
def silent_delete(self): def silent_delete(self):

View File

@@ -9,4 +9,4 @@ template_dest: '_build'
receptor_image: quay.io/ansible/receptor:devel receptor_image: quay.io/ansible/receptor:devel
# Helper vars to construct the proper download URL for the current architecture # Helper vars to construct the proper download URL for the current architecture
image_architecture: '{{ { "x86_64": "amd64", "aarch64": "arm64", "armv7": "arm", "ppc64le": "ppc64le" }[ansible_facts.architecture] }}' image_architecture: '{{ { "x86_64": "amd64", "aarch64": "arm64", "armv7": "arm", "arm64": "arm64", "ppc64le": "ppc64le" }[ansible_facts.architecture] }}'

View File

@@ -9,8 +9,8 @@ addons:
minikube_url_linux: 'https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64' minikube_url_linux: 'https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64'
minikube_url_macos: 'https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-amd64' minikube_url_macos: 'https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-amd64'
kubectl_url_linux: 'https://dl.k8s.io/release/v1.21.0/bin/linux/amd64/kubectl' kubectl_url_linux: 'https://dl.k8s.io/release/v1.25.0/bin/linux/amd64/kubectl'
kubectl_url_macos: 'https://dl.k8s.io/release/v1.21.0/bin/darwin/amd64/kubectl' kubectl_url_macos: 'https://dl.k8s.io/release/v1.25.0/bin/darwin/amd64/kubectl'
# Service Account Name # Service Account Name
minikube_service_account_name: 'awx-devel' minikube_service_account_name: 'awx-devel'

View File

@@ -8,6 +8,10 @@
state: 'directory' state: 'directory'
mode: '0700' mode: '0700'
- name: debug minikube_setup
debug:
var: minikube_setup
# Linux block # Linux block
- block: - block:
- name: Download Minikube - name: Download Minikube
@@ -24,6 +28,7 @@
when: when:
- ansible_architecture == "x86_64" - ansible_architecture == "x86_64"
- ansible_system == "Linux" - ansible_system == "Linux"
- minikube_setup | default(False) | bool
# MacOS block # MacOS block
- block: - block:
@@ -41,25 +46,29 @@
when: when:
- ansible_architecture == "x86_64" - ansible_architecture == "x86_64"
- ansible_system == "Darwin" - ansible_system == "Darwin"
- minikube_setup | default(False) | bool
- name: Starting Minikube - block:
shell: "{{ sources_dest }}/minikube start --driver={{ driver }} --install-addons=true --addons={{ addons | join(',') }}" - name: Starting Minikube
register: minikube_stdout shell: "{{ sources_dest }}/minikube start --driver={{ driver }} --install-addons=true --addons={{ addons | join(',') }}"
register: minikube_stdout
- name: Enable Ingress Controller on Minikube - name: Enable Ingress Controller on Minikube
shell: "{{ sources_dest }}/minikube addons enable ingress" shell: "{{ sources_dest }}/minikube addons enable ingress"
when:
- minikube_stdout.rc == 0
register: _minikube_ingress
ignore_errors: true
- name: Show Minikube Ingress known-issue 7332 warning
pause:
seconds: 5
prompt: "The Minikube Ingress addon has been disabled since it looks like you are hitting https://github.com/kubernetes/minikube/issues/7332"
when:
- '"minikube/issues/7332" in _minikube_ingress.stderr'
- ansible_system == "Darwin"
when: when:
- minikube_stdout.rc == 0 - minikube_setup | default(False) | bool
register: _minikube_ingress
ignore_errors: true
- name: Show Minikube Ingress known-issue 7332 warning
pause:
seconds: 5
prompt: "The Minikube Ingress addon has been disabled since it looks like you are hitting https://github.com/kubernetes/minikube/issues/7332"
when:
- '"minikube/issues/7332" in _minikube_ingress.stderr'
- ansible_system == "Darwin"
- name: Create ServiceAccount and clusterRoleBinding - name: Create ServiceAccount and clusterRoleBinding
k8s: k8s:

View File

@@ -28,7 +28,7 @@ Here are the main `make` targets:
Notable files: Notable files:
- `tools/docker-compose/inventory` file - used to configure the AWX development environment. - `tools/docker-compose/inventory` file - used to configure the AWX development environment.
- `migrate.yml` - playbook for migrating data from Local Docker to the Development Environment - `tools/docker-compose/ansible/migrate.yml` - playbook for migrating data from Local Docker to the Development Environment
### Prerequisites ### Prerequisites
@@ -301,11 +301,19 @@ Note that you may see multiple messages of the form `2021-03-04 20:11:47,666 WAR
To bring up a 1 node AWX + minikube that is accessible from AWX run the following. To bring up a 1 node AWX + minikube that is accessible from AWX run the following.
Start minikube
```bash
(host)$minikube start --cpus=4 --memory=8g --addons=ingress`
```
Start AWX
```bash ```bash
(host)$ make docker-compose-container-group (host)$ make docker-compose-container-group
``` ```
Alternatively, you can set the env var `MINIKUBE_CONTAINER_GROUP=true` to use the default dev env bring up. his way you can use other env flags like the cluster node count. Alternatively, you can set the env var `MINIKUBE_CONTAINER_GROUP=true` to use the default dev env bring up. his way you can use other env flags like the cluster node count. Set `MINIKUBE_SETUP=true` to make the roles download, install and run minikube for you, but if you run into issues with this just start minikube yourself.
```bash ```bash
(host)$ MINIKUBE_CONTAINER_GROUP=true make docker-compose (host)$ MINIKUBE_CONTAINER_GROUP=true make docker-compose

View File

@@ -19,6 +19,9 @@ else
wait-for-migrations wait-for-migrations
fi fi
# Make sure that the UI static file directory exists, Django complains otherwise.
mkdir -p /awx_devel/awx/ui/build/static
if output=$(awx-manage createsuperuser --noinput --username=admin --email=admin@localhost 2> /dev/null); then if output=$(awx-manage createsuperuser --noinput --username=admin --email=admin@localhost 2> /dev/null); then
echo $output echo $output
fi fi
@@ -27,10 +30,6 @@ echo "Admin password: ${DJANGO_SUPERUSER_PASSWORD}"
awx-manage create_preload_data awx-manage create_preload_data
awx-manage register_default_execution_environments awx-manage register_default_execution_environments
mkdir -p /awx_devel/awx/public/static
mkdir -p /awx_devel/awx/ui/static
mkdir -p /awx_devel/awx/ui/build/static
awx-manage provision_instance --hostname="$(hostname)" --node_type="$MAIN_NODE_TYPE" awx-manage provision_instance --hostname="$(hostname)" --node_type="$MAIN_NODE_TYPE"
awx-manage register_queue --queuename=controlplane --instance_percent=100 awx-manage register_queue --queuename=controlplane --instance_percent=100
awx-manage register_queue --queuename=default --instance_percent=100 awx-manage register_queue --queuename=default --instance_percent=100