Merge pull request #21 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan 2020-11-10 08:13:52 -06:00 committed by GitHub
commit 0fd0f0c1bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
284 changed files with 9133 additions and 4349 deletions

3
.gitignore vendored
View File

@ -34,8 +34,6 @@ awx/ui_next/coverage/
awx/ui_next/build
awx/ui_next/.env.local
rsyslog.pid
/tower-license
/tower-license/**
tools/prometheus/data
tools/docker-compose/Dockerfile
@ -147,3 +145,4 @@ use_dev_supervisor.txt
.idea/*
*.unison.tmp
*.#
/tools/docker-compose/overrides/

View File

@ -2,6 +2,22 @@
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
## 15.0.1 (October 20, 2020)
- Added several optimizations to improve performance for a variety of high-load simultaneous job launch use cases https://github.com/ansible/awx/pull/8403
- Added the ability to source roles and collections from requirements.yaml files (not just requirements.yml) - https://github.com/ansible/awx/issues/4540
- awx.awx collection modules now provide a clearer error message for incompatible versions of awxkit - https://github.com/ansible/awx/issues/8127
- Fixed a bug in notification messages that contain certain unicode characters - https://github.com/ansible/awx/issues/7400
- Fixed a bug that prevents the deletion of Workflow Approval records - https://github.com/ansible/awx/issues/8305
- Fixed a bug that broke the selection of webhook credentials - https://github.com/ansible/awx/issues/7892
- Fixed a bug which can cause confusing behavior for social auth logins across distinct browser tabs - https://github.com/ansible/awx/issues/8154
- Fixed several bugs in the output of Workflow Job Templates using the `awx export` tool - https://github.com/ansible/awx/issues/7798 https://github.com/ansible/awx/pull/7847
- Fixed a race condition that can lead to missing hosts when running parallel inventory syncs - https://github.com/ansible/awx/issues/5571
- Fixed an HTTP 500 error when certain LDAP group parameters aren't properly set - https://github.com/ansible/awx/issues/7622
- Updated a few dependencies in response to several CVEs:
* CVE-2020-7720
* CVE-2020-7743
* CVE-2020-7676
## 15.0.0 (September 30, 2020)
- Added improved support for fetching Ansible collections from private Galaxy content sources (such as https://github.com/ansible/galaxy_ng) - https://github.com/ansible/awx/issues/7813
**Note:** as part of this change, new Organizations created in the AWX API will _no longer_ automatically synchronize roles and collections from galaxy.ansible.com by default. More details on this change can be found at: https://github.com/ansible/awx/issues/8341#issuecomment-707310633

View File

@ -78,6 +78,8 @@ Before you can run a deployment, you'll need the following installed in your loc
- [docker](https://pypi.org/project/docker/) Python module
+ This is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it.
+ We use this module instead of `docker-py` because it is what the `docker-compose` Python module requires.
- [community.general.docker_image collection](https://docs.ansible.com/ansible/latest/collections/community/general/docker_image_module.html)
+ This is only required if you are using Ansible >= 2.10
- [GNU Make](https://www.gnu.org/software/make/)
- [Git](https://git-scm.com/) Requires Version 1.8.4+
- Python 3.6+

View File

@ -214,7 +214,11 @@ requirements_awx_dev:
requirements_collections:
mkdir -p $(COLLECTION_BASE)
ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE)
n=0; \
until [ "$$n" -ge 5 ]; do \
ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE) && break; \
n=$$((n+1)); \
done
requirements: requirements_ansible requirements_awx requirements_collections
@ -646,9 +650,11 @@ awx/projects:
docker-compose-isolated: awx/projects
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-isolated-override.yml up
COMPOSE_UP_OPTS ?=
# Docker Compose Development environment
docker-compose: docker-auth awx/projects
CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml up --no-recreate awx
CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml $(COMPOSE_UP_OPTS) up --no-recreate awx
docker-compose-cluster: docker-auth awx/projects
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up

View File

@ -1 +1 @@
15.0.0
15.0.1

View File

@ -47,8 +47,6 @@ from awx.main.utils import (
get_object_or_400,
decrypt_field,
get_awx_version,
get_licenser,
StubLicense
)
from awx.main.utils.db import get_all_field_names
from awx.main.views import ApiErrorView
@ -189,7 +187,8 @@ class APIView(views.APIView):
'''
Log warning for 400 requests. Add header with elapsed time.
'''
from awx.main.utils import get_licenser
from awx.main.utils.licensing import OpenLicense
#
# If the URL was rewritten, and we get a 404, we should entirely
# replace the view in the request context with an ApiErrorView()
@ -225,7 +224,8 @@ class APIView(views.APIView):
response = super(APIView, self).finalize_response(request, response, *args, **kwargs)
time_started = getattr(self, 'time_started', None)
response['X-API-Product-Version'] = get_awx_version()
response['X-API-Product-Name'] = 'AWX' if isinstance(get_licenser(), StubLicense) else 'Red Hat Ansible Tower'
response['X-API-Product-Name'] = 'AWX' if isinstance(get_licenser(), OpenLicense) else 'Red Hat Ansible Tower'
response['X-API-Node'] = settings.CLUSTER_HOST_ID
if time_started:
time_elapsed = time.time() - self.time_started

View File

@ -453,7 +453,7 @@ class BaseSerializer(serializers.ModelSerializer, metaclass=BaseSerializerMetacl
if 'capability_map' not in self.context:
if hasattr(self, 'polymorphic_base'):
model = self.polymorphic_base.Meta.model
prefetch_list = self.polymorphic_base._capabilities_prefetch
prefetch_list = self.polymorphic_base.capabilities_prefetch
else:
model = self.Meta.model
prefetch_list = self.capabilities_prefetch
@ -640,12 +640,9 @@ class EmptySerializer(serializers.Serializer):
class UnifiedJobTemplateSerializer(BaseSerializer):
# As a base serializer, the capabilities prefetch is not used directly
_capabilities_prefetch = [
'admin', 'execute',
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
'organization.workflow_admin']}
]
# 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.
capabilities_prefetch = []
class Meta:
model = UnifiedJobTemplate
@ -695,7 +692,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
serializer.polymorphic_base = self
# capabilities prefetch is only valid for these models
if isinstance(obj, (JobTemplate, WorkflowJobTemplate)):
serializer.capabilities_prefetch = self._capabilities_prefetch
serializer.capabilities_prefetch = serializer_class.capabilities_prefetch
else:
serializer.capabilities_prefetch = None
return serializer.to_representation(obj)
@ -1333,6 +1330,8 @@ class ProjectOptionsSerializer(BaseSerializer):
scm_type = attrs.get('scm_type', u'') or u''
if self.instance and not scm_type:
valid_local_paths.append(self.instance.local_path)
if self.instance and scm_type and "local_path" in attrs and self.instance.local_path != attrs['local_path']:
errors['local_path'] = _(f'Cannot change local_path for {scm_type}-based projects')
if scm_type:
attrs.pop('local_path', None)
if 'local_path' in attrs and attrs['local_path'] not in valid_local_paths:

View File

@ -8,7 +8,7 @@ The `period` of the data can be adjusted with:
?period=month
Where `month` can be replaced with `week`, or `day`. `month` is the default.
Where `month` can be replaced with `week`, `two_weeks`, or `day`. `month` is the default.
The type of job can be filtered with:

View File

@ -15,6 +15,7 @@ from awx.api.views import (
ApiV2PingView,
ApiV2ConfigView,
ApiV2SubscriptionView,
ApiV2AttachView,
AuthView,
UserMeList,
DashboardView,
@ -94,6 +95,7 @@ v2_urls = [
url(r'^ping/$', ApiV2PingView.as_view(), name='api_v2_ping_view'),
url(r'^config/$', ApiV2ConfigView.as_view(), name='api_v2_config_view'),
url(r'^config/subscriptions/$', ApiV2SubscriptionView.as_view(), name='api_v2_subscription_view'),
url(r'^config/attach/$', ApiV2AttachView.as_view(), name='api_v2_attach_view'),
url(r'^auth/$', AuthView.as_view()),
url(r'^me/$', UserMeList.as_view(), name='user_me_list'),
url(r'^dashboard/$', DashboardView.as_view(), name='dashboard_view'),

View File

@ -153,6 +153,7 @@ from awx.api.views.root import ( # noqa
ApiV2PingView,
ApiV2ConfigView,
ApiV2SubscriptionView,
ApiV2AttachView,
)
from awx.api.views.webhooks import ( # noqa
WebhookKeyView,
@ -316,6 +317,9 @@ class DashboardJobsGraphView(APIView):
if period == 'month':
end_date = start_date - dateutil.relativedelta.relativedelta(months=1)
interval = 'days'
elif period == 'two_weeks':
end_date = start_date - dateutil.relativedelta.relativedelta(weeks=2)
interval = 'days'
elif period == 'week':
end_date = start_date - dateutil.relativedelta.relativedelta(weeks=1)
interval = 'days'
@ -3043,7 +3047,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView):
approval_template,
context=self.get_serializer_context()
).data
return Response(data, status=status.HTTP_200_OK)
return Response(data, status=status.HTTP_201_CREATED)
def check_permissions(self, request):
obj = self.get_object().workflow_job_template
@ -4253,7 +4257,9 @@ class NotificationTemplateDetail(RetrieveUpdateDestroyAPIView):
obj = self.get_object()
if not request.user.can_access(self.model, 'delete', obj):
return Response(status=status.HTTP_404_NOT_FOUND)
if obj.notifications.filter(status='pending').exists():
hours_old = now() - dateutil.relativedelta.relativedelta(hours=8)
if obj.notifications.filter(status='pending', created__gt=hours_old).exists():
return Response({"error": _("Delete not allowed while there are pending notifications")},
status=status.HTTP_405_METHOD_NOT_ALLOWED)
return super(NotificationTemplateDetail, self).delete(request, *args, **kwargs)

View File

@ -1,9 +1,10 @@
# Copyright (c) 2018 Ansible, Inc.
# All Rights Reserved.
import base64
import json
import logging
import operator
import json
from collections import OrderedDict
from django.conf import settings
@ -29,8 +30,8 @@ from awx.main.utils import (
get_custom_venv_choices,
to_python_boolean,
)
from awx.main.utils.licensing import validate_entitlement_manifest
from awx.api.versioning import reverse, drf_reverse
from awx.conf.license import get_license
from awx.main.constants import PRIVILEGE_ESCALATION_METHODS
from awx.main.models import (
Project,
@ -178,7 +179,7 @@ class ApiV2PingView(APIView):
class ApiV2SubscriptionView(APIView):
permission_classes = (IsAuthenticated,)
name = _('Configuration')
name = _('Subscriptions')
swagger_topic = 'System Configuration'
def check_permissions(self, request):
@ -189,18 +190,18 @@ class ApiV2SubscriptionView(APIView):
def post(self, request):
from awx.main.utils.common import get_licenser
data = request.data.copy()
if data.get('rh_password') == '$encrypted$':
data['rh_password'] = settings.REDHAT_PASSWORD
if data.get('subscriptions_password') == '$encrypted$':
data['subscriptions_password'] = settings.SUBSCRIPTIONS_PASSWORD
try:
user, pw = data.get('rh_username'), data.get('rh_password')
user, pw = data.get('subscriptions_username'), data.get('subscriptions_password')
with set_environ(**settings.AWX_TASK_ENV):
validated = get_licenser().validate_rh(user, pw)
if user:
settings.REDHAT_USERNAME = data['rh_username']
settings.SUBSCRIPTIONS_USERNAME = data['subscriptions_username']
if pw:
settings.REDHAT_PASSWORD = data['rh_password']
settings.SUBSCRIPTIONS_PASSWORD = data['subscriptions_password']
except Exception as exc:
msg = _("Invalid License")
msg = _("Invalid Subscription")
if (
isinstance(exc, requests.exceptions.HTTPError) and
getattr(getattr(exc, 'response', None), 'status_code', None) == 401
@ -213,13 +214,63 @@ class ApiV2SubscriptionView(APIView):
elif isinstance(exc, (ValueError, OSError)) and exc.args:
msg = exc.args[0]
else:
logger.exception(smart_text(u"Invalid license submitted."),
logger.exception(smart_text(u"Invalid subscription submitted."),
extra=dict(actor=request.user.username))
return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST)
return Response(validated)
class ApiV2AttachView(APIView):
permission_classes = (IsAuthenticated,)
name = _('Attach Subscription')
swagger_topic = 'System Configuration'
def check_permissions(self, request):
super(ApiV2AttachView, self).check_permissions(request)
if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}:
self.permission_denied(request) # Raises PermissionDenied exception.
def post(self, request):
data = request.data.copy()
pool_id = data.get('pool_id', None)
if not pool_id:
return Response({"error": _("No subscription pool ID provided.")}, status=status.HTTP_400_BAD_REQUEST)
user = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None)
pw = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None)
if pool_id and user and pw:
from awx.main.utils.common import get_licenser
data = request.data.copy()
try:
with set_environ(**settings.AWX_TASK_ENV):
validated = get_licenser().validate_rh(user, pw)
except Exception as exc:
msg = _("Invalid Subscription")
if (
isinstance(exc, requests.exceptions.HTTPError) and
getattr(getattr(exc, 'response', None), 'status_code', None) == 401
):
msg = _("The provided credentials are invalid (HTTP 401).")
elif isinstance(exc, requests.exceptions.ProxyError):
msg = _("Unable to connect to proxy server.")
elif isinstance(exc, requests.exceptions.ConnectionError):
msg = _("Could not connect to subscription service.")
elif isinstance(exc, (ValueError, OSError)) and exc.args:
msg = exc.args[0]
else:
logger.exception(smart_text(u"Invalid subscription submitted."),
extra=dict(actor=request.user.username))
return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST)
for sub in validated:
if sub['pool_id'] == pool_id:
sub['valid_key'] = True
settings.LICENSE = sub
return Response(sub)
return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST)
class ApiV2ConfigView(APIView):
permission_classes = (IsAuthenticated,)
@ -234,15 +285,11 @@ class ApiV2ConfigView(APIView):
def get(self, request, format=None):
'''Return various sitewide configuration settings'''
if request.user.is_superuser or request.user.is_system_auditor:
license_data = get_license(show_key=True)
else:
license_data = get_license(show_key=False)
from awx.main.utils.common import get_licenser
license_data = get_licenser().validate()
if not license_data.get('valid_key', False):
license_data = {}
if license_data and 'features' in license_data and 'activity_streams' in license_data['features']:
# FIXME: Make the final setting value dependent on the feature?
license_data['features']['activity_streams'] &= settings.ACTIVITY_STREAM_ENABLED
pendo_state = settings.PENDO_TRACKING_STATE if settings.PENDO_TRACKING_STATE in ('off', 'anonymous', 'detailed') else 'off'
@ -281,9 +328,10 @@ class ApiV2ConfigView(APIView):
return Response(data)
def post(self, request):
if not isinstance(request.data, dict):
return Response({"error": _("Invalid license data")}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": _("Invalid subscription data")}, status=status.HTTP_400_BAD_REQUEST)
if "eula_accepted" not in request.data:
return Response({"error": _("Missing 'eula_accepted' property")}, status=status.HTTP_400_BAD_REQUEST)
try:
@ -300,25 +348,47 @@ class ApiV2ConfigView(APIView):
logger.info(smart_text(u"Invalid JSON submitted for license."),
extra=dict(actor=request.user.username))
return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST)
try:
from awx.main.utils.common import get_licenser
license_data = json.loads(data_actual)
license_data_validated = get_licenser(**license_data).validate()
except Exception:
logger.warning(smart_text(u"Invalid license submitted."),
extra=dict(actor=request.user.username))
return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST)
from awx.main.utils.common import get_licenser
license_data = json.loads(data_actual)
if 'license_key' in license_data:
return Response({"error": _('Legacy license submitted. A subscription manifest is now required.')}, status=status.HTTP_400_BAD_REQUEST)
if 'manifest' in license_data:
try:
json_actual = json.loads(base64.b64decode(license_data['manifest']))
if 'license_key' in json_actual:
return Response(
{"error": _('Legacy license submitted. A subscription manifest is now required.')},
status=status.HTTP_400_BAD_REQUEST
)
except Exception:
pass
try:
license_data = validate_entitlement_manifest(license_data['manifest'])
except ValueError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
logger.exception('Invalid manifest submitted. {}')
return Response({"error": _('Invalid manifest submitted.')}, status=status.HTTP_400_BAD_REQUEST)
try:
license_data_validated = get_licenser().license_from_manifest(license_data)
except Exception:
logger.warning(smart_text(u"Invalid subscription submitted."),
extra=dict(actor=request.user.username))
return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST)
else:
license_data_validated = get_licenser().validate()
# If the license is valid, write it to the database.
if license_data_validated['valid_key']:
settings.LICENSE = license_data
if not settings_registry.is_setting_read_only('TOWER_URL_BASE'):
settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host())
return Response(license_data_validated)
logger.warning(smart_text(u"Invalid license submitted."),
logger.warning(smart_text(u"Invalid subscription submitted."),
extra=dict(actor=request.user.username))
return Response({"error": _("Invalid license")}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": _("Invalid subscription")}, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request):
try:

View File

@ -25,10 +25,12 @@ if MODE == 'production':
try:
fd = open("/var/lib/awx/.tower_version", "r")
if fd.read().strip() != tower_version:
raise Exception()
except Exception:
raise ValueError()
except FileNotFoundError:
pass
except ValueError as e:
logger.error("Missing or incorrect metadata for Tower version. Ensure Tower was installed using the setup playbook.")
raise Exception("Missing or incorrect metadata for Tower version. Ensure Tower was installed using the setup playbook.")
raise Exception("Missing or incorrect metadata for Tower version. Ensure Tower was installed using the setup playbook.") from e
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings")

View File

@ -1,18 +1,14 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
__all__ = ['get_license']
def _get_validated_license_data():
from awx.main.utils.common import get_licenser
from awx.main.utils import get_licenser
return get_licenser().validate()
def get_license(show_key=False):
def get_license():
"""Return a dictionary representing the active license on this Tower instance."""
license_data = _get_validated_license_data()
if not show_key:
license_data.pop('license_key', None)
return license_data
return _get_validated_license_data()

View File

@ -0,0 +1,26 @@
# Generated by Django 2.2.11 on 2020-08-04 15:19
import logging
from django.db import migrations
from awx.conf.migrations._subscriptions import clear_old_license, prefill_rh_credentials
logger = logging.getLogger('awx.conf.migrations')
def _noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('conf', '0007_v380_rename_more_settings'),
]
operations = [
migrations.RunPython(clear_old_license, _noop),
migrations.RunPython(prefill_rh_credentials, _noop)
]

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import logging
from django.utils.timezone import now
from awx.main.utils.encryption import decrypt_field, encrypt_field
logger = logging.getLogger('awx.conf.settings')
__all__ = ['clear_old_license', 'prefill_rh_credentials']
def clear_old_license(apps, schema_editor):
Setting = apps.get_model('conf', 'Setting')
Setting.objects.filter(key='LICENSE').delete()
def _migrate_setting(apps, old_key, new_key, encrypted=False):
Setting = apps.get_model('conf', 'Setting')
if not Setting.objects.filter(key=old_key).exists():
return
new_setting = Setting.objects.create(key=new_key,
created=now(),
modified=now()
)
if encrypted:
new_setting.value = decrypt_field(Setting.objects.filter(key=old_key).first(), 'value')
new_setting.value = encrypt_field(new_setting, 'value')
else:
new_setting.value = getattr(Setting.objects.filter(key=old_key).first(), 'value')
new_setting.save()
def prefill_rh_credentials(apps, schema_editor):
_migrate_setting(apps, 'REDHAT_USERNAME', 'SUBSCRIPTIONS_USERNAME', encrypted=False)
_migrate_setting(apps, 'REDHAT_PASSWORD', 'SUBSCRIPTIONS_PASSWORD', encrypted=True)

View File

@ -78,14 +78,6 @@ class Setting(CreatedModifiedModel):
def get_cache_id_key(self, key):
return '{}_ID'.format(key)
def display_value(self):
if self.key == 'LICENSE' and 'license_key' in self.value:
# don't log the license key in activity stream
value = self.value.copy()
value['license_key'] = '********'
return value
return self.value
import awx.conf.signals # noqa

View File

@ -33,9 +33,9 @@ data _since_ the last report date - i.e., new data in the last 24 hours)
'''
@register('config', '1.1', description=_('General platform configuration.'))
@register('config', '1.2', description=_('General platform configuration.'))
def config(since, **kwargs):
license_info = get_license(show_key=False)
license_info = get_license()
install_type = 'traditional'
if os.environ.get('container') == 'oci':
install_type = 'openshift'

View File

@ -24,7 +24,7 @@ logger = logging.getLogger('awx.main.analytics')
def _valid_license():
try:
if get_license(show_key=False).get('license_type', 'UNLICENSED') == 'open':
if get_license().get('license_type', 'UNLICENSED') == 'open':
return False
access_registry[Job](None).check_license()
except PermissionDenied:

View File

@ -54,7 +54,7 @@ LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining
def metrics():
license_info = get_license(show_key=False)
license_info = get_license()
SYSTEM_INFO.info({
'install_uuid': settings.INSTALL_UUID,
'insights_analytics': str(settings.INSIGHTS_TRACKING_STATE),

View File

@ -1,7 +1,5 @@
# Python
import json
import logging
import os
# Django
from django.utils.translation import ugettext_lazy as _
@ -13,6 +11,7 @@ from rest_framework.fields import FloatField
# Tower
from awx.conf import fields, register, register_validate
logger = logging.getLogger('awx.main.conf')
register(
@ -92,22 +91,10 @@ register(
)
def _load_default_license_from_file():
try:
license_file = os.environ.get('AWX_LICENSE_FILE', '/etc/tower/license')
if os.path.exists(license_file):
license_data = json.load(open(license_file))
logger.debug('Read license data from "%s".', license_file)
return license_data
except Exception:
logger.warning('Could not read license from "%s".', license_file, exc_info=True)
return {}
register(
'LICENSE',
field_class=fields.DictField,
default=_load_default_license_from_file,
default=lambda: {},
label=_('License'),
help_text=_('The license controls which features and functionality are '
'enabled. Use /api/v2/config/ to update or change '
@ -124,7 +111,7 @@ register(
encrypted=False,
read_only=False,
label=_('Red Hat customer username'),
help_text=_('This username is used to retrieve license information and to send Automation Analytics'), # noqa
help_text=_('This username is used to send data to Automation Analytics'),
category=_('System'),
category_slug='system',
)
@ -137,7 +124,33 @@ register(
encrypted=True,
read_only=False,
label=_('Red Hat customer password'),
help_text=_('This password is used to retrieve license information and to send Automation Analytics'), # noqa
help_text=_('This password is used to send data to Automation Analytics'),
category=_('System'),
category_slug='system',
)
register(
'SUBSCRIPTIONS_USERNAME',
field_class=fields.CharField,
default='',
allow_blank=True,
encrypted=False,
read_only=False,
label=_('Red Hat or Satellite username'),
help_text=_('This username is used to retrieve subscription and content information'), # noqa
category=_('System'),
category_slug='system',
)
register(
'SUBSCRIPTIONS_PASSWORD',
field_class=fields.CharField,
default='',
allow_blank=True,
encrypted=True,
read_only=False,
label=_('Red Hat or Satellite password'),
help_text=_('This password is used to retrieve subscription and content information'), # noqa
category=_('System'),
category_slug='system',
)

View File

@ -1,10 +1,7 @@
import cProfile
import json
import logging
import os
import pstats
import signal
import tempfile
import time
import traceback
@ -23,6 +20,7 @@ from awx.main.models import (JobEvent, AdHocCommandEvent, ProjectUpdateEvent,
Job)
from awx.main.tasks import handle_success_and_failure_notifications
from awx.main.models.events import emit_event_detail
from awx.main.utils.profiling import AWXProfiler
from .base import BaseWorker
@ -48,6 +46,7 @@ class CallbackBrokerWorker(BaseWorker):
self.buff = {}
self.pid = os.getpid()
self.redis = redis.Redis.from_url(settings.BROKER_URL)
self.prof = AWXProfiler("CallbackBrokerWorker")
for key in self.redis.keys('awx_callback_receiver_statistics_*'):
self.redis.delete(key)
@ -87,19 +86,12 @@ class CallbackBrokerWorker(BaseWorker):
)
def toggle_profiling(self, *args):
if self.prof:
self.prof.disable()
filename = f'callback-{self.pid}.pstats'
filepath = os.path.join(tempfile.gettempdir(), filename)
with open(filepath, 'w') as f:
pstats.Stats(self.prof, stream=f).sort_stats('cumulative').print_stats()
pstats.Stats(self.prof).dump_stats(filepath + '.raw')
self.prof = False
logger.error(f'profiling is disabled, wrote {filepath}')
else:
self.prof = cProfile.Profile()
self.prof.enable()
if not self.prof.is_started():
self.prof.start()
logger.error('profiling is enabled')
else:
filepath = self.prof.stop()
logger.error(f'profiling is disabled, wrote {filepath}')
def work_loop(self, *args, **kw):
if settings.AWX_CALLBACK_PROFILE:

View File

@ -18,7 +18,5 @@ class Command(BaseCommand):
super(Command, self).__init__()
license = get_licenser().validate()
if options.get('data'):
if license.get('license_key', '') != 'UNLICENSED':
license['license_key'] = '********'
return json.dumps(license)
return license.get('license_type', 'none')

View File

@ -8,5 +8,7 @@ class Command(MakeMigrations):
def execute(self, *args, **options):
settings = connections['default'].settings_dict.copy()
settings['ENGINE'] = 'sqlite3'
if 'application_name' in settings['OPTIONS']:
del settings['OPTIONS']['application_name']
connections['default'] = DatabaseWrapper(settings)
return MakeMigrations().execute(*args, **options)

View File

@ -0,0 +1,117 @@
# Python
import asciichartpy as chart
import collections
import time
import sys
# Django
from django.db.models import Count
from django.core.management.base import BaseCommand
# AWX
from awx.main.models import (
Job,
Instance
)
DEFAULT_WIDTH = 100
DEFAULT_HEIGHT = 30
def chart_color_lookup(color_str):
return getattr(chart, color_str)
def clear_screen():
print(chr(27) + "[2J")
class JobStatus():
def __init__(self, status, color, width):
self.status = status
self.color = color
self.color_code = chart_color_lookup(color)
self.x = collections.deque(maxlen=width)
self.y = collections.deque(maxlen=width)
def tick(self, x, y):
self.x.append(x)
self.y.append(y)
class JobStatusController:
RESET = chart_color_lookup('reset')
def __init__(self, width):
self.plots = [
JobStatus('pending', 'red', width),
JobStatus('waiting', 'blue', width),
JobStatus('running', 'green', width)
]
self.ts_start = int(time.time())
def tick(self):
ts = int(time.time()) - self.ts_start
q = Job.objects.filter(status__in=['pending','waiting','running']).values_list('status').order_by().annotate(Count('status'))
status_count = dict(pending=0, waiting=0, running=0)
for status, count in q:
status_count[status] = count
for p in self.plots:
p.tick(ts, status_count[p.status])
def series(self):
return [list(p.y) for p in self.plots]
def generate_status(self):
line = ""
lines = []
for p in self.plots:
lines.append(f'{p.color_code}{p.status} {p.y[-1]}{self.RESET}')
line += ", ".join(lines) + '\n'
width = 5
time_running = int(time.time()) - self.ts_start
instances = Instance.objects.all().order_by('hostname')
line += "Capacity: " + ", ".join([f"{instance.capacity:{width}}" for instance in instances]) + '\n'
line += "Remaining: " + ", ".join([f"{instance.remaining_capacity:{width}}" for instance in instances]) + '\n'
line += f"Seconds running: {time_running}" + '\n'
return line
class Command(BaseCommand):
help = "Plot pending, waiting, running jobs over time on the terminal"
def add_arguments(self, parser):
parser.add_argument('--refresh', dest='refresh', type=float, default=1.0,
help='Time between refreshes of the graph and data in seconds (defaults to 1.0)')
parser.add_argument('--width', dest='width', type=int, default=DEFAULT_WIDTH,
help=f'Width of the graph (defaults to {DEFAULT_WIDTH})')
parser.add_argument('--height', dest='height', type=int, default=DEFAULT_HEIGHT,
help=f'Height of the graph (defaults to {DEFAULT_HEIGHT})')
def handle(self, *args, **options):
refresh_seconds = options['refresh']
width = options['width']
height = options['height']
jctl = JobStatusController(width)
conf = {
'colors': [chart_color_lookup(p.color) for p in jctl.plots],
'height': height,
}
while True:
jctl.tick()
draw = chart.plot(jctl.series(), conf)
status_line = jctl.generate_status()
clear_screen()
print(draw)
sys.stdout.write(status_line)
time.sleep(refresh_seconds)

View File

@ -903,7 +903,7 @@ class Command(BaseCommand):
def check_license(self):
license_info = get_licenser().validate()
local_license_type = license_info.get('license_type', 'UNLICENSED')
if license_info.get('license_key', 'UNLICENSED') == 'UNLICENSED':
if local_license_type == 'UNLICENSED':
logger.error(LICENSE_NON_EXISTANT_MESSAGE)
raise CommandError('No license found!')
elif local_license_type == 'open':

View File

@ -19,7 +19,9 @@ class Command(BaseCommand):
profile_sql.delay(
threshold=options['threshold'], minutes=options['minutes']
)
print(f"Logging initiated with a threshold of {options['threshold']} second(s) and a duration of"
f" {options['minutes']} minute(s), any queries that meet criteria can"
f" be found in /var/log/tower/profile/."
)
if options['threshold'] > 0:
print(f"SQL profiling initiated with a threshold of {options['threshold']} second(s) and a"
f" duration of {options['minutes']} minute(s), any queries that meet criteria can"
f" be found in /var/log/tower/profile/.")
else:
print("SQL profiling disabled.")

View File

@ -1,13 +1,9 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import uuid
import logging
import threading
import time
import cProfile
import pstats
import os
import urllib.parse
from django.conf import settings
@ -22,6 +18,7 @@ from django.urls import reverse, resolve
from awx.main.utils.named_url_graph import generate_graph, GraphNode
from awx.conf import fields, register
from awx.main.utils.profiling import AWXProfiler
logger = logging.getLogger('awx.main.middleware')
@ -32,11 +29,14 @@ class TimingMiddleware(threading.local, MiddlewareMixin):
dest = '/var/log/tower/profile'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.prof = AWXProfiler("TimingMiddleware")
def process_request(self, request):
self.start_time = time.time()
if settings.AWX_REQUEST_PROFILE:
self.prof = cProfile.Profile()
self.prof.enable()
self.prof.start()
def process_response(self, request, response):
if not hasattr(self, 'start_time'): # some tools may not invoke process_request
@ -44,33 +44,10 @@ class TimingMiddleware(threading.local, MiddlewareMixin):
total_time = time.time() - self.start_time
response['X-API-Total-Time'] = '%0.3fs' % total_time
if settings.AWX_REQUEST_PROFILE:
self.prof.disable()
cprofile_file = self.save_profile_file(request)
response['cprofile_file'] = cprofile_file
response['X-API-Profile-File'] = self.prof.stop()
perf_logger.info('api response times', extra=dict(python_objects=dict(request=request, response=response)))
return response
def save_profile_file(self, request):
if not os.path.isdir(self.dest):
os.makedirs(self.dest)
filename = '%.3fs-%s.pstats' % (pstats.Stats(self.prof).total_tt, uuid.uuid4())
filepath = os.path.join(self.dest, filename)
with open(filepath, 'w') as f:
f.write('%s %s\n' % (request.method, request.get_full_path()))
pstats.Stats(self.prof, stream=f).sort_stats('cumulative').print_stats()
if settings.AWX_REQUEST_PROFILE_WITH_DOT:
from gprof2dot import main as generate_dot
raw = os.path.join(self.dest, filename) + '.raw'
pstats.Stats(self.prof).dump_stats(raw)
generate_dot([
'-n', '2.5', '-f', 'pstats', '-o',
os.path.join( self.dest, filename).replace('.pstats', '.dot'),
raw
])
os.remove(raw)
return filepath
class SessionTimeoutMiddleware(MiddlewareMixin):
"""

View File

@ -1,11 +1,7 @@
# Generated by Django 2.2.11 on 2020-05-01 13:25
from django.db import migrations, models
from awx.main.migrations._inventory_source import create_scm_script_substitute
def convert_cloudforms_to_scm(apps, schema_editor):
create_scm_script_substitute(apps, 'cloudforms')
from awx.main.migrations._inventory_source import delete_cloudforms_inv_source
class Migration(migrations.Migration):
@ -15,7 +11,7 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(convert_cloudforms_to_scm),
migrations.RunPython(delete_cloudforms_inv_source),
migrations.AlterField(
model_name='inventorysource',
name='source',

View File

@ -5,6 +5,7 @@ from uuid import uuid4
from django.utils.encoding import smart_text
from django.utils.timezone import now
from awx.main.utils.common import set_current_apps
from awx.main.utils.common import parse_yaml_or_json
logger = logging.getLogger('awx.main.migrations')
@ -91,43 +92,14 @@ def back_out_new_instance_id(apps, source, new_id):
))
def create_scm_script_substitute(apps, source):
"""Only applies for cloudforms in practice, but written generally.
Given a source type, this will replace all inventory sources of that type
with SCM inventory sources that source the script from Ansible core
"""
# the revision in the Ansible 2.9 stable branch this project will start out as
# it can still be updated manually later (but staying within 2.9 branch), if desired
ansible_rev = '6f83b9aff42331e15c55a171de0a8b001208c18c'
def delete_cloudforms_inv_source(apps, schema_editor):
set_current_apps(apps)
InventorySource = apps.get_model('main', 'InventorySource')
ContentType = apps.get_model('contenttypes', 'ContentType')
Project = apps.get_model('main', 'Project')
if not InventorySource.objects.filter(source=source).exists():
logger.debug('No sources of type {} to migrate'.format(source))
return
proj_name = 'Replacement project for {} type sources - {}'.format(source, uuid4())
right_now = now()
project = Project.objects.create(
name=proj_name,
created=right_now,
modified=right_now,
description='Created by migration',
polymorphic_ctype=ContentType.objects.get(model='project'),
# project-specific fields
scm_type='git',
scm_url='https://github.com/ansible/ansible.git',
scm_branch='stable-2.9',
scm_revision=ansible_rev
)
ct = 0
for inv_src in InventorySource.objects.filter(source=source).iterator():
inv_src.source = 'scm'
inv_src.source_project = project
inv_src.source_path = 'contrib/inventory/{}.py'.format(source)
inv_src.scm_last_revision = ansible_rev
inv_src.save(update_fields=['source', 'source_project', 'source_path', 'scm_last_revision'])
logger.debug('Changed inventory source {} to scm type'.format(inv_src.pk))
ct += 1
InventoryUpdate = apps.get_model('main', 'InventoryUpdate')
CredentialType = apps.get_model('main', 'CredentialType')
InventoryUpdate.objects.filter(inventory_source__source='cloudforms').delete()
InventorySource.objects.filter(source='cloudforms').delete()
ct = CredentialType.objects.filter(namespace='cloudforms').first()
if ct:
logger.info('Changed total of {} inventory sources from {} type to scm'.format(ct, source))
ct.credentials.all().delete()
ct.delete()

View File

@ -881,33 +881,6 @@ ManagedCredentialType(
}
)
ManagedCredentialType(
namespace='cloudforms',
kind='cloud',
name=ugettext_noop('Red Hat CloudForms'),
managed_by_tower=True,
inputs={
'fields': [{
'id': 'host',
'label': ugettext_noop('CloudForms URL'),
'type': 'string',
'help_text': ugettext_noop('Enter the URL for the virtual machine that '
'corresponds to your CloudForms instance. '
'For example, https://cloudforms.example.org')
}, {
'id': 'username',
'label': ugettext_noop('Username'),
'type': 'string'
}, {
'id': 'password',
'label': ugettext_noop('Password'),
'type': 'string',
'secret': True,
}],
'required': ['host', 'username', 'password'],
}
)
ManagedCredentialType(
namespace='gce',
kind='cloud',

View File

@ -261,18 +261,20 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin):
app_label = 'main'
def fit_task_to_most_remaining_capacity_instance(self, task):
@staticmethod
def fit_task_to_most_remaining_capacity_instance(task, instances):
instance_most_capacity = None
for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
for i in instances:
if i.remaining_capacity >= task.task_impact and \
(instance_most_capacity is None or
i.remaining_capacity > instance_most_capacity.remaining_capacity):
instance_most_capacity = i
return instance_most_capacity
def find_largest_idle_instance(self):
@staticmethod
def find_largest_idle_instance(instances):
largest_instance = None
for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
for i in instances:
if i.jobs_running == 0:
if largest_instance is None:
largest_instance = i

View File

@ -798,6 +798,10 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
if self.project:
for name in ('awx', 'tower'):
r['{}_project_revision'.format(name)] = self.project.scm_revision
r['{}_project_scm_branch'.format(name)] = self.project.scm_branch
if self.scm_branch:
for name in ('awx', 'tower'):
r['{}_job_scm_branch'.format(name)] = self.scm_branch
if self.job_template:
for name in ('awx', 'tower'):
r['{}_job_template_id'.format(name)] = self.job_template.pk

View File

@ -873,7 +873,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
# If status changed, update the parent instance.
if self.status != status_before:
self._update_parent_instance()
# Update parent outside of the transaction for Job w/ allow_simultaneous=True
# This dodges lock contention at the expense of the foreign key not being
# completely correct.
if getattr(self, 'allow_simultaneous', False):
connection.on_commit(self._update_parent_instance)
else:
self._update_parent_instance()
# Done.
return result

View File

@ -57,6 +57,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
def send_messages(self, messages):
sent_messages = 0
self.headers['Content-Type'] = 'application/json'
if 'User-Agent' not in self.headers:
self.headers['User-Agent'] = "Tower {}".format(get_awx_version())
if self.http_method.lower() not in ['put','post']:
@ -68,7 +69,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
auth = (self.username, self.password)
r = chosen_method("{}".format(m.recipients()[0]),
auth=auth,
json=m.body,
data=json.dumps(m.body, ensure_ascii=False).encode('utf-8'),
headers=self.headers,
verify=(not self.disable_ssl_verification))
if r.status_code >= 400:

View File

@ -12,6 +12,24 @@ from awx.main.utils.common import parse_yaml_or_json
logger = logging.getLogger('awx.main.scheduler')
def deepmerge(a, b):
"""
Merge dict structures and return the result.
>>> a = {'first': {'all_rows': {'pass': 'dog', 'number': '1'}}}
>>> b = {'first': {'all_rows': {'fail': 'cat', 'number': '5'}}}
>>> import pprint; pprint.pprint(deepmerge(a, b))
{'first': {'all_rows': {'fail': 'cat', 'number': '5', 'pass': 'dog'}}}
"""
if isinstance(a, dict) and isinstance(b, dict):
return dict([(k, deepmerge(a.get(k), b.get(k)))
for k in set(a.keys()).union(b.keys())])
elif b is None:
return a
else:
return b
class PodManager(object):
def __init__(self, task=None):
@ -128,11 +146,13 @@ class PodManager(object):
pod_spec = {**default_pod_spec, **pod_spec_override}
if self.task:
pod_spec['metadata']['name'] = self.pod_name
pod_spec['metadata']['labels'] = {
'ansible-awx': settings.INSTALL_UUID,
'ansible-awx-job-id': str(self.task.id)
}
pod_spec['metadata'] = deepmerge(
pod_spec.get('metadata', {}),
dict(name=self.pod_name,
labels={
'ansible-awx': settings.INSTALL_UUID,
'ansible-awx-job-id': str(self.task.id)
}))
pod_spec['spec']['containers'][0]['name'] = self.pod_name
return pod_spec

View File

@ -7,12 +7,14 @@ import logging
import uuid
import json
import random
from types import SimpleNamespace
# Django
from django.db import transaction, connection
from django.utils.translation import ugettext_lazy as _, gettext_noop
from django.utils.timezone import now as tz_now
from django.conf import settings
from django.db.models import Q
# AWX
from awx.main.dispatch.reaper import reap_job
@ -45,6 +47,15 @@ logger = logging.getLogger('awx.main.scheduler')
class TaskManager():
def __init__(self):
'''
Do NOT put database queries or other potentially expensive operations
in the task manager init. The task manager object is created every time a
job is created, transitions state, and every 30 seconds on each tower node.
More often then not, the object is destroyed quickly because the NOOP case is hit.
The NOOP case is short-circuit logic. If the task manager realizes that another instance
of the task manager is already running, then it short-circuits and decides not to run.
'''
self.graph = dict()
# start task limit indicates how many pending jobs can be started on this
# .schedule() run. Starting jobs is expensive, and there is code in place to reap
@ -52,10 +63,30 @@ class TaskManager():
# 5 minutes to start pending jobs. If this limit is reached, pending jobs
# will no longer be started and will be started on the next task manager cycle.
self.start_task_limit = settings.START_TASK_LIMIT
def after_lock_init(self):
'''
Init AFTER we know this instance of the task manager will run because the lock is acquired.
'''
instances = Instance.objects.filter(~Q(hostname=None), capacity__gt=0, enabled=True)
self.real_instances = {i.hostname: i for i in instances}
instances_partial = [SimpleNamespace(obj=instance,
remaining_capacity=instance.remaining_capacity,
capacity=instance.capacity,
jobs_running=instance.jobs_running,
hostname=instance.hostname) for instance in instances]
instances_by_hostname = {i.hostname: i for i in instances_partial}
for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name),
capacity_total=rampart_group.capacity,
consumed_capacity=0)
consumed_capacity=0,
instances=[])
for instance in rampart_group.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
if instance.hostname in instances_by_hostname:
self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname])
def is_job_blocked(self, task):
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
@ -254,7 +285,7 @@ class TaskManager():
for group in InstanceGroup.objects.all():
if group.is_containerized or group.controller_id:
continue
match = group.fit_task_to_most_remaining_capacity_instance(task)
match = group.fit_task_to_most_remaining_capacity_instance(task, group.instances.all())
if match:
break
task.instance_group = rampart_group
@ -466,7 +497,6 @@ class TaskManager():
continue
preferred_instance_groups = task.preferred_instance_groups
found_acceptable_queue = False
idle_instance_that_fits = None
if isinstance(task, WorkflowJob):
if task.unified_job_template_id in running_workflow_templates:
if not task.allow_simultaneous:
@ -483,24 +513,24 @@ class TaskManager():
found_acceptable_queue = True
break
if idle_instance_that_fits is None:
idle_instance_that_fits = rampart_group.find_largest_idle_instance()
remaining_capacity = self.get_remaining_capacity(rampart_group.name)
if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0:
logger.debug("Skipping group {}, remaining_capacity {} <= 0".format(
rampart_group.name, remaining_capacity))
continue
execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task)
if execution_instance:
logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity))
elif not execution_instance and idle_instance_that_fits:
execution_instance = InstanceGroup.fit_task_to_most_remaining_capacity_instance(task, self.graph[rampart_group.name]['instances']) or \
InstanceGroup.find_largest_idle_instance(self.graph[rampart_group.name]['instances'])
if execution_instance or rampart_group.is_containerized:
if not rampart_group.is_containerized:
execution_instance = idle_instance_that_fits
execution_instance.remaining_capacity = max(0, execution_instance.remaining_capacity - task.task_impact)
execution_instance.jobs_running += 1
logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity))
if execution_instance or rampart_group.is_containerized:
if execution_instance:
execution_instance = self.real_instances[execution_instance.hostname]
self.graph[rampart_group.name]['graph'].add_job(task)
self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance)
found_acceptable_queue = True
@ -572,6 +602,9 @@ class TaskManager():
def _schedule(self):
finished_wfjs = []
all_sorted_tasks = self.get_tasks()
self.after_lock_init()
if len(all_sorted_tasks) > 0:
# TODO: Deal with
# latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks)

View File

@ -313,7 +313,7 @@ def delete_project_files(project_path):
@task(queue='tower_broadcast_all')
def profile_sql(threshold=1, minutes=1):
if threshold == 0:
if threshold <= 0:
cache.delete('awx-profile-sql-threshold')
logger.error('SQL PROFILING DISABLED')
else:
@ -2160,7 +2160,7 @@ class RunProjectUpdate(BaseTask):
'local_path': os.path.basename(project_update.project.local_path),
'project_path': project_update.get_project_path(check_if_exists=False), # deprecated
'insights_url': settings.INSIGHTS_URL_BASE,
'awx_license_type': get_license(show_key=False).get('license_type', 'UNLICENSED'),
'awx_license_type': get_license().get('license_type', 'UNLICENSED'),
'awx_version': get_awx_version(),
'scm_url': scm_url,
'scm_branch': scm_branch,

View File

@ -675,33 +675,6 @@ def test_net_create_ok(post, organization, admin):
assert cred.inputs['authorize'] is True
#
# Cloudforms Credentials
#
@pytest.mark.django_db
def test_cloudforms_create_ok(post, organization, admin):
params = {
'credential_type': 1,
'name': 'Best credential ever',
'inputs': {
'host': 'some_host',
'username': 'some_username',
'password': 'some_password',
}
}
cloudforms = CredentialType.defaults['cloudforms']()
cloudforms.save()
params['organization'] = organization.id
response = post(reverse('api:credential_list'), params, admin)
assert response.status_code == 201
assert Credential.objects.count() == 1
cred = Credential.objects.all()[:1].get()
assert cred.inputs['host'] == 'some_host'
assert cred.inputs['username'] == 'some_username'
assert decrypt_field(cred, 'password') == 'some_password'
#
# GCE Credentials
#

View File

@ -99,3 +99,12 @@ def test_changing_overwrite_behavior_okay_if_not_used(post, patch, organization,
expect=200
)
assert Project.objects.get(pk=r1.data['id']).allow_override is False
@pytest.mark.django_db
def test_scm_project_local_path_invalid(get, patch, project, admin):
url = reverse('api:project_detail', kwargs={'pk': project.id})
resp = patch(url, {'local_path': '/foo/bar'}, user=admin, expect=400)
assert resp.data['local_path'] == [
'Cannot change local_path for git-based projects'
]

View File

@ -282,10 +282,6 @@ def test_prefetch_ujt_project_capabilities(alice, project, job_template, mocker)
list_serializer.child.to_representation(project)
assert 'capability_map' not in list_serializer.child.context
# Models for which the prefetch is valid for do
list_serializer.child.to_representation(job_template)
assert set(list_serializer.child.context['capability_map'][job_template.id].keys()) == set(('copy', 'edit', 'start'))
@pytest.mark.django_db
def test_prefetch_group_capabilities(group, rando):

View File

@ -349,7 +349,7 @@ def test_months_with_31_days(post, admin_user):
('MINUTELY', 1, 60),
('MINUTELY', 15, 15 * 60),
('HOURLY', 1, 3600),
('HOURLY', 4, 3600 * 4),
('HOURLY', 2, 3600 * 2),
))
def test_really_old_dtstart(post, admin_user, freq, delta, total_seconds):
url = reverse('api:schedule_rrule')

File diff suppressed because one or more lines are too long

View File

@ -89,7 +89,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': approval_node.pk, 'version': 'v2'})
post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0},
user=admin_user, expect=200)
user=admin_user, expect=201)
approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk)
assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate)
@ -108,9 +108,9 @@ class TestApprovalNodes():
assert {'name': ['This field may not be blank.']} == json.loads(r.content)
@pytest.mark.parametrize("is_admin, is_org_admin, status", [
[True, False, 200], # if they're a WFJT admin, they get a 200
[True, False, 201], # if they're a WFJT admin, they get a 201
[False, False, 403], # if they're not a WFJT *nor* org admin, they get a 403
[False, True, 200], # if they're an organization admin, they get a 200
[False, True, 201], # if they're an organization admin, they get a 201
])
def test_approval_node_creation_rbac(self, post, approval_node, alice, is_admin, is_org_admin, status):
url = reverse('api:workflow_job_template_node_create_approval',
@ -165,7 +165,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': node.pk, 'version': 'v2'})
post(url, {'name': 'Approve Test', 'description': '', 'timeout': 0},
user=admin_user, expect=200)
user=admin_user, expect=201)
post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
user=admin_user, expect=201)
wf_job = WorkflowJob.objects.first()
@ -195,7 +195,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': node.pk, 'version': 'v2'})
post(url, {'name': 'Deny Test', 'description': '', 'timeout': 0},
user=admin_user, expect=200)
user=admin_user, expect=201)
post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
user=admin_user, expect=201)
wf_job = WorkflowJob.objects.first()

View File

@ -1,13 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
from awx.main.utils.common import StubLicense
def test_stub_license():
license_actual = StubLicense().validate()
assert license_actual['license_key'] == 'OPEN'
assert license_actual['valid_key']
assert license_actual['compliant']
assert license_actual['license_type'] == 'open'

View File

@ -79,7 +79,6 @@ def test_default_cred_types():
'aws',
'azure_kv',
'azure_rm',
'cloudforms',
'conjur',
'galaxy_api_token',
'gce',

View File

@ -5,7 +5,7 @@ from awx.main.migrations import _inventory_source as invsrc
from django.apps import apps
from awx.main.models import InventorySource
from awx.main.models import InventorySource, InventoryUpdate, ManagedCredentialType, CredentialType, Credential
@pytest.mark.parametrize('vars,id_var,result', [
@ -42,16 +42,40 @@ def test_apply_new_instance_id(inventory_source):
@pytest.mark.django_db
def test_replacement_scm_sources(inventory):
inv_source = InventorySource.objects.create(
name='test',
inventory=inventory,
organization=inventory.organization,
source='ec2'
def test_cloudforms_inventory_removal(inventory):
ManagedCredentialType(
name='Red Hat CloudForms',
namespace='cloudforms',
kind='cloud',
managed_by_tower=True,
inputs={},
)
invsrc.create_scm_script_substitute(apps, 'ec2')
inv_source.refresh_from_db()
assert inv_source.source == 'scm'
assert inv_source.source_project
project = inv_source.source_project
assert 'Replacement project for' in project.name
CredentialType.defaults['cloudforms']().save()
cloudforms = CredentialType.objects.get(namespace='cloudforms')
Credential.objects.create(
name='test',
credential_type=cloudforms,
)
for source in ('ec2', 'cloudforms'):
i = InventorySource.objects.create(
name='test',
inventory=inventory,
organization=inventory.organization,
source=source,
)
InventoryUpdate.objects.create(
name='test update',
inventory_source=i,
source=source,
)
assert Credential.objects.count() == 1
assert InventorySource.objects.count() == 2 # ec2 + cf
assert InventoryUpdate.objects.count() == 2 # ec2 + cf
invsrc.delete_cloudforms_inv_source(apps, None)
assert InventorySource.objects.count() == 1 # ec2
assert InventoryUpdate.objects.count() == 1 # ec2
assert InventorySource.objects.first().source == 'ec2'
assert InventoryUpdate.objects.first().source == 'ec2'
assert Credential.objects.count() == 0
assert CredentialType.objects.filter(namespace='cloudforms').exists() is False

View File

@ -30,8 +30,7 @@ def test_python_and_js_licenses():
# Check variations of '-' and '_' in filenames due to python
for fname in [name, name.replace('-','_')]:
if entry.startswith(fname) and entry.endswith('.tar.gz'):
entry = entry[:-7]
(n, v) = entry.rsplit('-',1)
v = entry.split(name + '-')[1].split('.tar.gz')[0]
return v
return None

View File

@ -45,19 +45,14 @@ class TestInstanceGroup(object):
(T(100), Is([50, 0, 20, 99, 11, 1, 5, 99]), None, "The task don't a fit, you must a quit!"),
])
def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason):
with mock.patch.object(InstanceGroup,
'instances',
Mock(spec_set=['filter'],
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
order_by=lambda x: instances))):
ig = InstanceGroup(id=10)
ig = InstanceGroup(id=10)
if instance_fit_index is None:
assert ig.fit_task_to_most_remaining_capacity_instance(task) is None, reason
else:
assert ig.fit_task_to_most_remaining_capacity_instance(task) == \
instances[instance_fit_index], reason
instance_picked = ig.fit_task_to_most_remaining_capacity_instance(task, instances)
if instance_fit_index is None:
assert instance_picked is None, reason
else:
assert instance_picked == instances[instance_fit_index], reason
@pytest.mark.parametrize('instances,instance_fit_index,reason', [
(Is([(0, 100)]), 0, "One idle instance, pick it"),
@ -70,16 +65,12 @@ class TestInstanceGroup(object):
def filter_offline_instances(*args):
return filter(lambda i: i.capacity > 0, instances)
with mock.patch.object(InstanceGroup,
'instances',
Mock(spec_set=['filter'],
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
order_by=filter_offline_instances))):
ig = InstanceGroup(id=10)
ig = InstanceGroup(id=10)
instances_online_only = filter_offline_instances(instances)
if instance_fit_index is None:
assert ig.find_largest_idle_instance() is None, reason
else:
assert ig.find_largest_idle_instance() == \
instances[instance_fit_index], reason
if instance_fit_index is None:
assert ig.find_largest_idle_instance(instances_online_only) is None, reason
else:
assert ig.find_largest_idle_instance(instances_online_only) == \
instances[instance_fit_index], reason

View File

@ -39,6 +39,8 @@ from awx.main import tasks
from awx.main.utils import encrypt_field, encrypt_value
from awx.main.utils.safe_yaml import SafeLoader
from awx.main.utils.licensing import Licenser
class TestJobExecution(object):
EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----'
@ -1830,7 +1832,10 @@ class TestProjectUpdateGalaxyCredentials(TestJobExecution):
task = RunProjectUpdate()
env = task.build_env(project_update, private_data_dir)
task.build_extra_vars_file(project_update, private_data_dir)
with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}):
task.build_extra_vars_file(project_update, private_data_dir)
assert task.__vars__['roles_enabled'] is False
assert task.__vars__['collections_enabled'] is False
for k in env:
@ -1850,7 +1855,10 @@ class TestProjectUpdateGalaxyCredentials(TestJobExecution):
project_update.project.organization.galaxy_credentials.add(public_galaxy)
task = RunProjectUpdate()
env = task.build_env(project_update, private_data_dir)
task.build_extra_vars_file(project_update, private_data_dir)
with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}):
task.build_extra_vars_file(project_update, private_data_dir)
assert task.__vars__['roles_enabled'] is True
assert task.__vars__['collections_enabled'] is True
assert sorted([
@ -1935,7 +1943,9 @@ class TestProjectUpdateCredentials(TestJobExecution):
assert settings.PROJECTS_ROOT in process_isolation['process_isolation_show_paths']
task._write_extra_vars_file = mock.Mock()
task.build_extra_vars_file(project_update, private_data_dir)
with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}):
task.build_extra_vars_file(project_update, private_data_dir)
call_args, _ = task._write_extra_vars_file.call_args_list[0]
_, extra_vars = call_args

View File

@ -55,8 +55,7 @@ __all__ = [
'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError',
'get_custom_venv_choices', 'get_external_account', 'task_manager_bulk_reschedule',
'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout',
'StubLicense'
'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout'
]
@ -190,7 +189,7 @@ def get_awx_version():
def get_awx_http_client_headers():
license = get_license(show_key=False).get('license_type', 'UNLICENSED')
license = get_license().get('license_type', 'UNLICENSED')
headers = {
'Content-Type': 'application/json',
'User-Agent': '{} {} ({})'.format(
@ -202,34 +201,15 @@ def get_awx_http_client_headers():
return headers
class StubLicense(object):
features = {
'activity_streams': True,
'ha': True,
'ldap': True,
'multiple_organizations': True,
'surveys': True,
'system_tracking': True,
'rebranding': True,
'enterprise_auth': True,
'workflows': True,
}
def validate(self):
return dict(license_key='OPEN',
valid_key=True,
compliant=True,
features=self.features,
license_type='open')
def get_licenser(*args, **kwargs):
from awx.main.utils.licensing import Licenser, OpenLicense
try:
from tower_license import TowerLicense
return TowerLicense(*args, **kwargs)
except ImportError:
return StubLicense(*args, **kwargs)
if os.path.exists('/var/lib/awx/.tower_version'):
return Licenser(*args, **kwargs)
else:
return OpenLicense()
except Exception as e:
raise ValueError(_('Error importing Tower License: %s') % e)
def update_scm_url(scm_type, url, username=True, password=True,

390
awx/main/utils/licensing.py Normal file
View File

@ -0,0 +1,390 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
'''
This is intended to be a lightweight license class for verifying subscriptions, and parsing subscription data
from entitlement certificates.
The Licenser class can do the following:
- Parse an Entitlement cert to generate license
'''
import base64
import configparser
from datetime import datetime
import collections
import copy
import io
import json
import logging
import re
import requests
import time
import zipfile
from dateutil.parser import parse as parse_date
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography import x509
# Django
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
# AWX
from awx.main.models import Host
MAX_INSTANCES = 9999999
logger = logging.getLogger(__name__)
def rhsm_config():
path = '/etc/rhsm/rhsm.conf'
config = configparser.ConfigParser()
config.read(path)
return config
def validate_entitlement_manifest(data):
buff = io.BytesIO()
buff.write(base64.b64decode(data))
try:
z = zipfile.ZipFile(buff)
except zipfile.BadZipFile as e:
raise ValueError(_("Invalid manifest: a subscription manifest zip file is required.")) from e
buff = io.BytesIO()
files = z.namelist()
if 'consumer_export.zip' not in files or 'signature' not in files:
raise ValueError(_("Invalid manifest: missing required files."))
export = z.open('consumer_export.zip').read()
sig = z.open('signature').read()
with open('/etc/tower/candlepin-redhat-ca.crt', 'rb') as f:
cert = x509.load_pem_x509_certificate(f.read(), backend=default_backend())
key = cert.public_key()
try:
key.verify(sig, export, padding=padding.PKCS1v15(), algorithm=hashes.SHA256())
except InvalidSignature as e:
raise ValueError(_("Invalid manifest: signature verification failed.")) from e
buff.write(export)
z = zipfile.ZipFile(buff)
for f in z.filelist:
if f.filename.startswith('export/entitlements') and f.filename.endswith('.json'):
return json.loads(z.open(f).read())
raise ValueError(_("Invalid manifest: manifest contains no subscriptions."))
class OpenLicense(object):
def validate(self):
return dict(
license_type='open',
valid_key=True,
subscription_name='OPEN',
product_name="AWX",
)
class Licenser(object):
# warn when there is a month (30 days) left on the subscription
SUBSCRIPTION_TIMEOUT = 60 * 60 * 24 * 30
UNLICENSED_DATA = dict(
subscription_name=None,
sku=None,
support_level=None,
instance_count=0,
license_date=0,
license_type="UNLICENSED",
product_name="Red Hat Ansible Automation Platform",
valid_key=False
)
def __init__(self, **kwargs):
self._attrs = dict(
instance_count=0,
license_date=0,
license_type='UNLICENSED',
)
self.config = rhsm_config()
if not kwargs:
license_setting = getattr(settings, 'LICENSE', None)
if license_setting is not None:
kwargs = license_setting
if 'company_name' in kwargs:
kwargs.pop('company_name')
self._attrs.update(kwargs)
if 'valid_key' in self._attrs:
if not self._attrs['valid_key']:
self._unset_attrs()
else:
self._unset_attrs()
def _unset_attrs(self):
self._attrs = self.UNLICENSED_DATA.copy()
def license_from_manifest(self, manifest):
# Parse output for subscription metadata to build config
license = dict()
license['sku'] = manifest['pool']['productId']
license['instance_count'] = manifest['pool']['quantity']
license['subscription_name'] = manifest['pool']['productName']
license['pool_id'] = manifest['pool']['id']
license['license_date'] = parse_date(manifest['endDate']).strftime('%s')
license['product_name'] = manifest['pool']['productName']
license['valid_key'] = True
license['license_type'] = 'enterprise'
license['satellite'] = False
self._attrs.update(license)
settings.LICENSE = self._attrs
return self._attrs
def update(self, **kwargs):
# Update attributes of the current license.
if 'instance_count' in kwargs:
kwargs['instance_count'] = int(kwargs['instance_count'])
if 'license_date' in kwargs:
kwargs['license_date'] = int(kwargs['license_date'])
self._attrs.update(kwargs)
def validate_rh(self, user, pw):
try:
host = 'https://' + str(self.config.get("server", "hostname"))
except Exception:
logger.exception('Cannot access rhsm.conf, make sure subscription manager is installed and configured.')
host = None
if not host:
host = getattr(settings, 'REDHAT_CANDLEPIN_HOST', None)
if not user:
raise ValueError('subscriptions_username is required')
if not pw:
raise ValueError('subscriptions_password is required')
if host and user and pw:
if 'subscription.rhsm.redhat.com' in host:
json = self.get_rhsm_subs(host, user, pw)
else:
json = self.get_satellite_subs(host, user, pw)
return self.generate_license_options_from_entitlements(json)
return []
def get_rhsm_subs(self, host, user, pw):
verify = getattr(settings, 'REDHAT_CANDLEPIN_VERIFY', True)
json = []
try:
subs = requests.get(
'/'.join([host, 'subscription/users/{}/owners'.format(user)]),
verify=verify,
auth=(user, pw)
)
except requests.exceptions.ConnectionError as error:
raise error
except OSError as error:
raise OSError('Unable to open certificate bundle {}. Check that Ansible Tower is running on Red Hat Enterprise Linux.'.format(verify)) from error # noqa
subs.raise_for_status()
for sub in subs.json():
resp = requests.get(
'/'.join([
host,
'subscription/owners/{}/pools/?match=*tower*'.format(sub['key'])
]),
verify=verify,
auth=(user, pw)
)
resp.raise_for_status()
json.extend(resp.json())
return json
def get_satellite_subs(self, host, user, pw):
try:
verify = str(self.config.get("rhsm", "repo_ca_cert"))
except Exception as e:
logger.exception('Unable to read rhsm config to get ca_cert location. {}'.format(str(e)))
verify = getattr(settings, 'REDHAT_CANDLEPIN_VERIFY', True)
json = []
try:
orgs = requests.get(
'/'.join([host, 'katello/api/organizations']),
verify=verify,
auth=(user, pw)
)
except requests.exceptions.ConnectionError as error:
raise error
except OSError as error:
raise OSError('Unable to open certificate bundle {}. Check that Ansible Tower is running on Red Hat Enterprise Linux.'.format(verify)) from error # noqa
orgs.raise_for_status()
for org in orgs.json()['results']:
resp = requests.get(
'/'.join([
host,
'/katello/api/organizations/{}/subscriptions/?search=Red Hat Ansible Automation'.format(org['id'])
]),
verify=verify,
auth=(user, pw)
)
resp.raise_for_status()
results = resp.json()['results']
if results != []:
for sub in results:
# Parse output for subscription metadata to build config
license = dict()
license['productId'] = sub['product_id']
license['quantity'] = int(sub['quantity'])
license['support_level'] = sub['support_level']
license['subscription_name'] = sub['name']
license['id'] = sub['upstream_pool_id']
license['endDate'] = sub['end_date']
license['productName'] = "Red Hat Ansible Automation"
license['valid_key'] = True
license['license_type'] = 'enterprise'
license['satellite'] = True
json.append(license)
return json
def is_appropriate_sat_sub(self, sub):
if 'Red Hat Ansible Automation' not in sub['subscription_name']:
return False
return True
def is_appropriate_sub(self, sub):
if sub['activeSubscription'] is False:
return False
# Products that contain Ansible Tower
products = sub.get('providedProducts', [])
if any(map(lambda product: product.get('productId', None) == "480", products)):
return True
return False
def generate_license_options_from_entitlements(self, json):
from dateutil.parser import parse
ValidSub = collections.namedtuple('ValidSub', 'sku name support_level end_date trial quantity pool_id satellite')
valid_subs = []
for sub in json:
satellite = sub.get('satellite')
if satellite:
is_valid = self.is_appropriate_sat_sub(sub)
else:
is_valid = self.is_appropriate_sub(sub)
if is_valid:
try:
end_date = parse(sub.get('endDate'))
except Exception:
continue
now = datetime.utcnow()
now = now.replace(tzinfo=end_date.tzinfo)
if end_date < now:
# If the sub has a past end date, skip it
continue
try:
quantity = int(sub['quantity'])
if quantity == -1:
# effectively, unlimited
quantity = MAX_INSTANCES
except Exception:
continue
sku = sub['productId']
trial = sku.startswith('S') # i.e.,, SER/SVC
support_level = ''
pool_id = sub['id']
if satellite:
support_level = sub['support_level']
else:
for attr in sub.get('productAttributes', []):
if attr.get('name') == 'support_level':
support_level = attr.get('value')
valid_subs.append(ValidSub(
sku, sub['productName'], support_level, end_date, trial, quantity, pool_id, satellite
))
if valid_subs:
licenses = []
for sub in valid_subs:
license = self.__class__(subscription_name='Red Hat Ansible Automation Platform')
license._attrs['instance_count'] = int(sub.quantity)
license._attrs['sku'] = sub.sku
license._attrs['support_level'] = sub.support_level
license._attrs['license_type'] = 'enterprise'
if sub.trial:
license._attrs['trial'] = True
license._attrs['license_type'] = 'trial'
license._attrs['instance_count'] = min(
MAX_INSTANCES, license._attrs['instance_count']
)
human_instances = license._attrs['instance_count']
if human_instances == MAX_INSTANCES:
human_instances = 'Unlimited'
subscription_name = re.sub(
r' \([\d]+ Managed Nodes',
' ({} Managed Nodes'.format(human_instances),
sub.name
)
license._attrs['subscription_name'] = subscription_name
license._attrs['satellite'] = satellite
license._attrs['valid_key'] = True
license.update(
license_date=int(sub.end_date.strftime('%s'))
)
license.update(
pool_id=sub.pool_id
)
licenses.append(license._attrs.copy())
return licenses
raise ValueError(
'No valid Red Hat Ansible Automation subscription could be found for this account.' # noqa
)
def validate(self):
# Return license attributes with additional validation info.
attrs = copy.deepcopy(self._attrs)
type = attrs.get('license_type', 'none')
if (type == 'UNLICENSED' or False):
attrs.update(dict(valid_key=False, compliant=False))
return attrs
attrs['valid_key'] = True
if Host:
current_instances = Host.objects.active_count()
else:
current_instances = 0
available_instances = int(attrs.get('instance_count', None) or 0)
attrs['current_instances'] = current_instances
attrs['available_instances'] = available_instances
free_instances = (available_instances - current_instances)
attrs['free_instances'] = max(0, free_instances)
license_date = int(attrs.get('license_date', 0) or 0)
current_date = int(time.time())
time_remaining = license_date - current_date
attrs['time_remaining'] = time_remaining
if attrs.setdefault('trial', False):
attrs['grace_period_remaining'] = time_remaining
else:
attrs['grace_period_remaining'] = (license_date + 2592000) - current_date
attrs['compliant'] = bool(time_remaining > 0 and free_instances >= 0)
attrs['date_warning'] = bool(time_remaining < self.SUBSCRIPTION_TIMEOUT)
attrs['date_expired'] = bool(time_remaining <= 0)
return attrs

151
awx/main/utils/profiling.py Normal file
View File

@ -0,0 +1,151 @@
import cProfile
import functools
import pstats
import os
import uuid
import datetime
import json
import sys
class AWXProfileBase:
def __init__(self, name, dest):
self.name = name
self.dest = dest
self.results = {}
def generate_results(self):
raise RuntimeError("define me")
def output_results(self, fname=None):
if not os.path.isdir(self.dest):
os.makedirs(self.dest)
if fname:
fpath = os.path.join(self.dest, fname)
with open(fpath, 'w') as f:
f.write(json.dumps(self.results, indent=2))
class AWXTiming(AWXProfileBase):
def __init__(self, name, dest='/var/log/tower/timing'):
super().__init__(name, dest)
self.time_start = None
self.time_end = None
def start(self):
self.time_start = datetime.datetime.now()
def stop(self):
self.time_end = datetime.datetime.now()
self.generate_results()
self.output_results()
def generate_results(self):
diff = (self.time_end - self.time_start).total_seconds()
self.results = {
'name': self.name,
'diff': f'{diff}-seconds',
}
def output_results(self):
fname = f"{self.results['diff']}-{self.name}-{uuid.uuid4()}.time"
super().output_results(fname)
def timing(name, *init_args, **init_kwargs):
def decorator_profile(func):
@functools.wraps(func)
def wrapper_profile(*args, **kwargs):
timing = AWXTiming(name, *init_args, **init_kwargs)
timing.start()
res = func(*args, **kwargs)
timing.stop()
return res
return wrapper_profile
return decorator_profile
class AWXProfiler(AWXProfileBase):
def __init__(self, name, dest='/var/log/tower/profile', dot_enabled=True):
'''
Try to do as little as possible in init. Instead, do the init
only when the profiling is started.
'''
super().__init__(name, dest)
self.started = False
self.dot_enabled = dot_enabled
self.results = {
'total_time_seconds': 0,
}
def generate_results(self):
self.results['total_time_seconds'] = pstats.Stats(self.prof).total_tt
def output_results(self):
super().output_results()
filename_base = '%.3fs-%s-%s-%s' % (self.results['total_time_seconds'], self.name, self.pid, uuid.uuid4())
pstats_filepath = os.path.join(self.dest, f"{filename_base}.pstats")
extra_data = ""
if self.dot_enabled:
try:
from gprof2dot import main as generate_dot
except ImportError:
extra_data = 'Dot graph generation failed due to package "gprof2dot" being unavailable.'
else:
raw_filepath = os.path.join(self.dest, f"{filename_base}.raw")
dot_filepath = os.path.join(self.dest, f"{filename_base}.dot")
pstats.Stats(self.prof).dump_stats(raw_filepath)
generate_dot([
'-n', '2.5', '-f', 'pstats', '-o',
dot_filepath,
raw_filepath
])
os.remove(raw_filepath)
with open(pstats_filepath, 'w') as f:
print(f"{self.name}, {extra_data}", file=f)
pstats.Stats(self.prof, stream=f).sort_stats('cumulative').print_stats()
return pstats_filepath
def start(self):
self.prof = cProfile.Profile()
self.pid = os.getpid()
self.prof.enable()
self.started = True
def is_started(self):
return self.started
def stop(self):
if self.started:
self.prof.disable()
self.generate_results()
res = self.output_results()
self.started = False
return res
else:
print("AWXProfiler::stop() called without calling start() first", file=sys.stderr)
return None
def profile(name, *init_args, **init_kwargs):
def decorator_profile(func):
@functools.wraps(func)
def wrapper_profile(*args, **kwargs):
prof = AWXProfiler(name, *init_args, **init_kwargs)
prof.start()
res = func(*args, **kwargs)
prof.stop()
return res
return wrapper_profile
return decorator_profile

View File

@ -159,23 +159,29 @@
gather_facts: false
connection: local
name: Install content with ansible-galaxy command if necessary
vars:
yaml_exts:
- {ext: .yml}
- {ext: .yaml}
tasks:
- block:
- name: detect requirements.yml
- name: detect roles/requirements.(yml/yaml)
stat:
path: '{{project_path|quote}}/roles/requirements.yml'
path: "{{project_path|quote}}/roles/requirements{{ item.ext }}"
with_items: "{{ yaml_exts }}"
register: doesRequirementsExist
- name: fetch galaxy roles from requirements.yml
- name: fetch galaxy roles from requirements.(yml/yaml)
command: >
ansible-galaxy role install -r roles/requirements.yml
ansible-galaxy role install -r {{ item.stat.path }}
--roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args:
chdir: "{{project_path|quote}}"
register: galaxy_result
when: doesRequirementsExist.stat.exists
with_items: "{{ doesRequirementsExist.results }}"
when: item.stat.exists
changed_when: "'was installed successfully' in galaxy_result.stdout"
environment:
ANSIBLE_FORCE_COLOR: false
@ -186,20 +192,22 @@
- install_roles
- block:
- name: detect collections/requirements.yml
- name: detect collections/requirements.(yml/yaml)
stat:
path: '{{project_path|quote}}/collections/requirements.yml'
path: "{{project_path|quote}}/collections/requirements{{ item.ext }}"
with_items: "{{ yaml_exts }}"
register: doesCollectionRequirementsExist
- name: fetch galaxy collections from collections/requirements.yml
- name: fetch galaxy collections from collections/requirements.(yml/yaml)
command: >
ansible-galaxy collection install -r collections/requirements.yml
ansible-galaxy collection install -r {{ item.stat.path }}
--collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args:
chdir: "{{project_path|quote}}"
register: galaxy_collection_result
when: doesCollectionRequirementsExist.stat.exists
with_items: "{{ doesCollectionRequirementsExist.results }}"
when: item.stat.exists
changed_when: "'Installing ' in galaxy_collection_result.stdout"
environment:
ANSIBLE_FORCE_COLOR: false

View File

@ -184,3 +184,6 @@ else:
pass
AWX_CALLBACK_PROFILE = True
if 'sqlite3' not in DATABASES['default']['ENGINE']: # noqa
DATABASES['default'].setdefault('OPTIONS', dict()).setdefault('application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63]) # noqa

View File

@ -102,6 +102,7 @@ except IOError:
else:
raise
# The below runs AFTER all of the custom settings are imported.
CELERYBEAT_SCHEDULE.update({ # noqa
'isolated_heartbeat': {
@ -110,3 +111,5 @@ CELERYBEAT_SCHEDULE.update({ # noqa
'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2}, # noqa
}
})
DATABASES['default'].setdefault('OPTIONS', dict()).setdefault('application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63]) # noqa

View File

@ -29,6 +29,7 @@ function AddEditCredentialsController (
const isExternal = credentialType.get('kind') === 'external';
const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit';
vm.isEditable = credential.get('summary_fields.user_capabilities.edit');
vm.mode = mode;
vm.strings = strings;
@ -52,6 +53,7 @@ function AddEditCredentialsController (
vm.form = credential.createFormSchema({ omit });
vm.form.disabled = !isEditable;
}
vm.form.disabled = !vm.isEditable;
vm.form._organization._disabled = !isOrgEditableByUser;
// Only exists for permissions compatibility

View File

@ -54,20 +54,20 @@ export default {
});
}],
resolve: {
rhCreds: ['Rest', 'GetBasePath', function(Rest, GetBasePath) {
subscriptionCreds: ['Rest', 'GetBasePath', function(Rest, GetBasePath) {
Rest.setUrl(`${GetBasePath('settings')}system/`);
return Rest.get()
.then(({data}) => {
const rhCreds = {};
if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") {
rhCreds.REDHAT_USERNAME = data.REDHAT_USERNAME;
const subscriptionCreds = {};
if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") {
subscriptionCreds.SUBSCRIPTIONS_USERNAME = data.SUBSCRIPTIONS_USERNAME;
}
if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") {
rhCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD;
if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") {
subscriptionCreds.SUBSCRIPTIONS_PASSWORD = data.SUBSCRIPTIONS_PASSWORD;
}
return rhCreds;
return subscriptionCreds;
}).catch(() => {
return {};
});

View File

@ -139,7 +139,7 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
else{
$scope.credentialBasePath = (source === 'ec2') ? GetBasePath('credentials') + '?credential_type__namespace=aws' : GetBasePath('credentials') + (source === '' ? '' : '?credential_type__namespace=' + (source));
}
if (source === 'ec2' || source === 'custom' || source === 'vmware' || source === 'openstack' || source === 'scm' || source === 'cloudforms' || source === "satellite6" || source === "azure_rm") {
if (true) {
$scope.envParseType = 'yaml';
var varName;

View File

@ -68,11 +68,7 @@ export default ['$state', '$scope', 'ParseVariableString', 'ParseTypeChange',
multiple: false
});
if (source === 'ec2' || source === 'custom' ||
source === 'vmware' || source === 'openstack' ||
source === 'scm' || source === 'cloudforms' ||
source === 'satellite6' || source === 'azure_rm') {
if (true) {
var varName;
if (source === 'scm') {
varName = 'custom_variables';

View File

@ -174,9 +174,11 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: "<p>" + i18n._("Override variables found in ec2.ini and used by the inventory update script. For a detailed description of these variables ") +
"<a href=\"https://github.com/ansible-collections/community.aws/blob/main/scripts/inventory/ec2.ini\" target=\"_blank\">" +
i18n._("view ec2.ini in the community.aws repo.") + "</a></p>" +
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/amazon/aws/aws_ec2_inventory.html\" target=\"blank\">aws_ec2</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
@ -198,9 +200,11 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: "<p>" + i18n._("Override variables found in vmware.ini and used by the inventory update script. For a detailed description of these variables ") +
"<a href=\"https://github.com/ansible-collections/vmware/blob/main/scripts/inventory/vmware_inventory.ini\" target=\"_blank\">" +
i18n._("view vmware_inventory.ini in the vmware community repo.") + "</a></p>" +
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html\" target=\"blank\">vmware_vm_inventory</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
@ -222,9 +226,18 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: i18n._("Override variables found in openstack.yml and used by the inventory update script. For an example variable configuration") +
'<a href=\"https://github.com/openstack/ansible-collections-openstack/blob/master/scripts/inventory/openstack.yml\" target=\"_blank\">' +
i18n._("view openstack.yml in the Openstack github repo.") + "</a>" + i18n._("Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax."),
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/openstack/cloud/openstack_inventory.html\" target=\"blank\">openstack</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
i18n._("YAML:") + "<br />\n" +
"<blockquote>---<br />somevar: somevalue<br />password: magic<br /></blockquote>\n" +
"<p>" + i18n._("View JSON examples at ") + '<a href="http://www.json.org" target="_blank">www.json.org</a></p>' +
"<p>" + i18n._("View YAML examples at ") + '<a href="http://docs.ansible.com/YAMLSyntax.html" target="_blank">docs.ansible.com</a></p>',
dataContainer: 'body',
subForm: 'sourceSubForm'
},
@ -256,9 +269,18 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: i18n._("Override variables found in foreman.ini and used by the inventory update script. For an example variable configuration") +
'<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/foreman.ini\" target=\"_blank\">' +
i18n._("view foreman.ini in the Ansible Collections github repo.") + "</a>" + i18n._("Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax."),
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/theforeman/foreman/foreman_inventory.html\" target=\"blank\">foreman</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
i18n._("YAML:") + "<br />\n" +
"<blockquote>---<br />somevar: somevalue<br />password: magic<br /></blockquote>\n" +
"<p>" + i18n._("View JSON examples at ") + '<a href="http://www.json.org" target="_blank">www.json.org</a></p>' +
"<p>" + i18n._("View YAML examples at ") + '<a href="http://docs.ansible.com/YAMLSyntax.html" target="_blank">docs.ansible.com</a></p>',
dataContainer: 'body',
subForm: 'sourceSubForm'
},
@ -273,9 +295,89 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: "<p>" + i18n._("Override variables found in azure_rm.ini and used by the inventory update script. For a detailed description of these variables ") +
"<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/azure_rm.ini\" target=\"_blank\">" +
i18n._("view azure_rm.ini in the Ansible community.general github repo.") + "</a></p>" +
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/azure/azcollection/azure_rm_inventory.html\" target=\"blank\">azure_rm</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
i18n._("YAML:") + "<br />\n" +
"<blockquote>---<br />somevar: somevalue<br />password: magic<br /></blockquote>\n" +
"<p>" + i18n._("View JSON examples at ") + '<a href="http://www.json.org" target="_blank">www.json.org</a></p>' +
"<p>" + i18n._("View YAML examples at ") + '<a href="http://docs.ansible.com/YAMLSyntax.html" target="_blank">docs.ansible.com</a></p>',
dataContainer: 'body',
subForm: 'sourceSubForm'
},
gce_variables: {
id: 'gce_variables',
label: i18n._('Source Variables'), //"{{vars_label}}" ,
ngShow: "source && source.value == 'gce'",
type: 'textarea',
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
rows: 6,
'default': '---',
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/google/cloud/gcp_compute_inventory.html\" target=\"blank\">gcp_compute</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
i18n._("YAML:") + "<br />\n" +
"<blockquote>---<br />somevar: somevalue<br />password: magic<br /></blockquote>\n" +
"<p>" + i18n._("View JSON examples at ") + '<a href="http://www.json.org" target="_blank">www.json.org</a></p>' +
"<p>" + i18n._("View YAML examples at ") + '<a href="http://docs.ansible.com/YAMLSyntax.html" target="_blank">docs.ansible.com</a></p>',
dataContainer: 'body',
subForm: 'sourceSubForm'
},
tower_variables: {
id: 'tower_variables',
label: i18n._('Source Variables'), //"{{vars_label}}" ,
ngShow: "source && source.value == 'tower'",
type: 'textarea',
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
rows: 6,
'default': '---',
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/awx/awx/tower_inventory.html\" target=\"blank\">tower</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
i18n._("YAML:") + "<br />\n" +
"<blockquote>---<br />somevar: somevalue<br />password: magic<br /></blockquote>\n" +
"<p>" + i18n._("View JSON examples at ") + '<a href="http://www.json.org" target="_blank">www.json.org</a></p>' +
"<p>" + i18n._("View YAML examples at ") + '<a href="http://docs.ansible.com/YAMLSyntax.html" target="_blank">docs.ansible.com</a></p>',
dataContainer: 'body',
subForm: 'sourceSubForm'
},
rhv_variables: {
id: 'rhv_variables',
label: i18n._('Source Variables'), //"{{vars_label}}" ,
ngShow: "source && source.value == 'rhv'",
type: 'textarea',
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
rows: 6,
'default': '---',
parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"),
dataPlacement: 'right',
awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("Inventory Plugins") + "</a> " + i18n._("in the documentation and the ") +
"<a href=\"https://docs.ansible.com/ansible/latest/collections/ovirt/ovirt/ovirt_inventory.html\" target=\"blank\">ovirt</a> " +
i18n._("plugin configuration guide.") + "</p>" +
"<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +

View File

@ -15,14 +15,26 @@ export default
return config.license_info;
},
post: function(payload, eula){
var defaultUrl = GetBasePath('config');
post: function(payload, eula, attach){
var defaultUrl = GetBasePath('config') + (attach ? 'attach/' : '');
Rest.setUrl(defaultUrl);
var data = payload;
data.eula_accepted = eula;
if (!attach) {
data.eula_accepted = eula;
}
return Rest.post(JSON.stringify(data))
.then((response) =>{
if (attach) {
var configPayload = {};
configPayload.eula_accepted = eula;
Rest.setUrl(GetBasePath('config'));
return Rest.post(configPayload)
.then((configResponse) => {
return configResponse.data;
});
}
return response.data;
})
.catch(({data}) => {

View File

@ -8,9 +8,9 @@ import {N_} from "../i18n";
export default
['Wait', '$state', '$scope', '$rootScope', 'ProcessErrors', 'CheckLicense', 'moment', '$timeout', 'Rest', 'LicenseStrings',
'$window', 'ConfigService', 'pendoService', 'insightsEnablementService', 'i18n', 'config', 'rhCreds', 'GetBasePath',
'$window', 'ConfigService', 'pendoService', 'insightsEnablementService', 'i18n', 'config', 'subscriptionCreds', 'GetBasePath',
function(Wait, $state, $scope, $rootScope, ProcessErrors, CheckLicense, moment, $timeout, Rest, LicenseStrings,
$window, ConfigService, pendoService, insightsEnablementService, i18n, config, rhCreds, GetBasePath) {
$window, ConfigService, pendoService, insightsEnablementService, i18n, config, subscriptionCreds, GetBasePath) {
$scope.strings = LicenseStrings;
@ -35,7 +35,7 @@ export default
const reset = function() {
$scope.newLicense.eula = undefined;
$scope.rhCreds = {};
$scope.subscriptionCreds = {};
$scope.selectedLicense = {};
};
@ -44,9 +44,9 @@ export default
$scope.fileName = N_("No file selected.");
if ($rootScope.licenseMissing) {
$scope.title = $rootScope.BRAND_NAME + i18n._(" License");
$scope.title = $rootScope.BRAND_NAME + i18n._(" Subscription");
} else {
$scope.title = i18n._("License Management");
$scope.title = i18n._("Subscription Management");
}
$scope.license = config;
@ -62,30 +62,30 @@ export default
insights: true
};
$scope.rhCreds = {};
$scope.subscriptionCreds = {};
if (rhCreds.REDHAT_USERNAME && rhCreds.REDHAT_USERNAME !== "") {
$scope.rhCreds.username = rhCreds.REDHAT_USERNAME;
if (subscriptionCreds.SUBSCRIPTIONS_USERNAME && subscriptionCreds.SUBSCRIPTIONS_USERNAME !== "") {
$scope.subscriptionCreds.username = subscriptionCreds.SUBSCRIPTIONS_USERNAME;
}
if (rhCreds.REDHAT_PASSWORD && rhCreds.REDHAT_PASSWORD !== "") {
$scope.rhCreds.password = rhCreds.REDHAT_PASSWORD;
if (subscriptionCreds.SUBSCRIPTIONS_PASSWORD && subscriptionCreds.SUBSCRIPTIONS_PASSWORD !== "") {
$scope.subscriptionCreds.password = subscriptionCreds.SUBSCRIPTIONS_PASSWORD;
$scope.showPlaceholderPassword = true;
}
};
const updateRHCreds = (config) => {
const updateSubscriptionCreds = (config) => {
Rest.setUrl(`${GetBasePath('settings')}system/`);
Rest.get()
.then(({data}) => {
initVars(config);
if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") {
$scope.rhCreds.username = data.REDHAT_USERNAME;
if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") {
$scope.subscriptionCreds.username = data.SUBSCRIPTIONS_USERNAME;
}
if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") {
$scope.rhCreds.password = data.REDHAT_PASSWORD;
if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") {
$scope.subscriptionCreds.password = data.SUBSCRIPTIONS_PASSWORD;
$scope.showPlaceholderPassword = true;
}
}).catch(() => {
@ -100,28 +100,23 @@ export default
$scope.fileName = event.target.files[0].name;
// Grab the key from the raw license file
const raw = new FileReader();
// readAsFoo runs async
raw.onload = function() {
try {
$scope.newLicense.file = JSON.parse(raw.result);
} catch(err) {
ProcessErrors($rootScope, null, null, null,
{msg: i18n._('Invalid file format. Please upload valid JSON.')});
}
$scope.newLicense.manifest = btoa(raw.result);
};
try {
raw.readAsText(event.target.files[0]);
raw.readAsBinaryString(event.target.files[0]);
} catch(err) {
ProcessErrors($rootScope, null, null, null,
{msg: i18n._('Invalid file format. Please upload valid JSON.')});
{msg: i18n._('Invalid file format. Please upload a valid Red Hat Subscription Manifest.')});
}
};
// HTML5 spec doesn't provide a way to customize file input css
// So we hide the default input, show our own, and simulate clicks to the hidden input
$scope.fakeClick = function() {
if($scope.user_is_superuser && (!$scope.rhCreds.username || $scope.rhCreds.username === '') && (!$scope.rhCreds.password || $scope.rhCreds.password === '')) {
if($scope.user_is_superuser && (!$scope.subscriptionCreds.username || $scope.subscriptionCreds.username === '') && (!$scope.subscriptionCreds.password || $scope.subscriptionCreds.password === '')) {
$('#License-file').click();
}
};
@ -131,9 +126,9 @@ export default
};
$scope.replacePassword = () => {
if ($scope.user_is_superuser && !$scope.newLicense.file) {
if ($scope.user_is_superuser && !$scope.newLicense.manifest) {
$scope.showPlaceholderPassword = false;
$scope.rhCreds.password = "";
$scope.subscriptionCreds.password = "";
$timeout(() => {
$('.tooltip').remove();
$('#rh-password').focus();
@ -142,9 +137,9 @@ export default
};
$scope.lookupLicenses = () => {
if ($scope.rhCreds.username && $scope.rhCreds.password) {
if ($scope.subscriptionCreds.username && $scope.subscriptionCreds.password) {
Wait('start');
ConfigService.getSubscriptions($scope.rhCreds.username, $scope.rhCreds.password)
ConfigService.getSubscriptions($scope.subscriptionCreds.username, $scope.subscriptionCreds.password)
.then(({data}) => {
Wait('stop');
if (data && data.length > 0) {
@ -172,29 +167,30 @@ export default
$scope.confirmLicenseSelection = () => {
$scope.showLicenseModal = false;
$scope.selectedLicense.fullLicense = $scope.rhLicenses.find((license) => {
return license.license_key === $scope.selectedLicense.modalKey;
return license.pool_id === $scope.selectedLicense.modalPoolId;
});
$scope.selectedLicense.modalKey = undefined;
$scope.selectedLicense.modalPoolId = undefined;
};
$scope.cancelLicenseLookup = () => {
$scope.showLicenseModal = false;
$scope.selectedLicense.modalKey = undefined;
$scope.selectedLicense.modalPoolId = undefined;
};
$scope.submit = function() {
Wait('start');
let payload = {};
if ($scope.newLicense.file) {
payload = $scope.newLicense.file;
let attach = false;
if ($scope.newLicense.manifest) {
payload.manifest = $scope.newLicense.manifest;
} else if ($scope.selectedLicense.fullLicense) {
payload = $scope.selectedLicense.fullLicense;
payload.pool_id = $scope.selectedLicense.fullLicense.pool_id;
attach = true;
}
CheckLicense.post(payload, $scope.newLicense.eula)
.then((licenseInfo) => {
CheckLicense.post(payload, $scope.newLicense.eula, attach)
.finally((licenseInfo) => {
reset();
ConfigService.delete();
ConfigService.getConfig(licenseInfo)
.then(function(config) {
@ -217,7 +213,7 @@ export default
licenseMissing: false
});
} else {
updateRHCreds(config);
updateSubscriptionCreds(config);
$scope.success = true;
$rootScope.licenseMissing = false;
// for animation purposes

View File

@ -5,10 +5,10 @@
<div class="List-titleText" translate>Details</div>
<div class="License-fields">
<div class="License-field">
<div class="License-field--label" translate>License</div>
<div class="License-field--label" translate>Subscription</div>
<div class="License-field--content">
<span class="License-greenText" ng-show='compliant'><i class="fa fa-circle License-greenText"></i><translate>Valid License</translate></span>
<span class="License-redText" ng-show='compliant !== undefined && !compliant'><i class="fa fa-circle License-redText"></i><translate>Invalid License</translate></span>
<span class="License-greenText" ng-show='compliant'><i class="fa fa-circle License-greenText"></i><translate>Compliant</translate></span>
<span class="License-redText" ng-show='compliant !== undefined && !compliant'><i class="fa fa-circle License-redText"></i><translate>Out of Compliance</translate></span>
</div>
</div>
<div class="License-field">
@ -18,7 +18,7 @@
</div>
</div>
<div class="License-field">
<div class="License-field--label" translate>License Type</div>
<div class="License-field--label" translate>Subscription Type</div>
<div class="License-field--content">
{{license.license_info.license_type}}
</div>
@ -29,12 +29,6 @@
{{license.license_info.subscription_name}}
</div>
</div>
<div class="License-field">
<div class="License-field--label" translate>License Key</div>
<div class="License-field--content License-field--key">
{{license.license_info.license_key}}
</div>
</div>
<div class="License-field">
<div class="License-field--label" translate>Expires On</div>
<div class="License-field--content">
@ -64,53 +58,66 @@
{{license.license_info.current_instances}}
</div>
</div>
<div class="License-field License-greenText" ng-show='license.license_info.available_instances < 9999999'>
<div class="License-field License-greenText" ng-show='license.license_info.available_instances < 9999999 && compliant'>
<div class="License-field--label" translate>Hosts Remaining</div>
<div class="License-field--content">
{{license.license_info.free_instances}}
</div>
</div>
<div class="License-field License-redText" ng-show='license.license_info.free_instances < 1 && !compliant'>
<div class="License-field--label" translate>Hosts Remaining</div>
<div class="License-field--content">
{{license.license_info.free_instances}}
</div>
</div>
</div>
<div class="License-upgradeText" translate>If you are ready to upgrade, please contact us by clicking the button below</div>
<a href="https://www.ansible.com/renew" target="_blank"><button class="btn btn-primary" translate>Upgrade</button></a>
<div class="License-upgradeText" translate>If you are ready to upgrade or renew, please contact us by clicking the button below.</div>
<a href="https://www.redhat.com/contact" target="_blank"><button class="btn btn-primary" translate>Contact Us</button></a>
</div>
</div>
<div class="License-management" ng-class="{'License-management--missingLicense' : licenseMissing}">
<div class="card at-Panel">
<div class="List-titleText">{{title}}</div>
<div class="License-body">
<div class="License-helperText License-introText" ng-if="licenseMissing" translate>Welcome to Ansible Tower! Please complete the steps below to acquire a license.</div>
<div class="License-helperText License-introText" ng-if="licenseMissing" translate>Welcome to Red Hat Ansible Automation Platform! Please complete the steps below to activate your subscription.</div>
<div class="AddPermissions-directions" ng-if="licenseMissing">
<span class="AddPermissions-directionNumber">
1
</span>
<span class="License-helperText">
<translate>If you do not have a subscription, you can visit Red Hat to obtain a trial subscription.</translate>
</span>
</div>
<button class="License-downloadLicenseButton btn btn-primary" ng-if="licenseMissing" ng-click="downloadLicense()">
<translate>Request Subscription</translate>
</button>
<div class="License-file-container">
<div class="AddPermissions-directions" ng-if="licenseMissing">
<span class="AddPermissions-directionNumber" ng-if="licenseMissing">
2
</span>
<span class="License-helperText">
<translate>Select your Ansible Automation Platform subscription to use.</translate>
</span>
</div>
</div>
<div class="input-group License-file--container">
<div class="License-file--left">
<div class="d-block w-100">
<div class="AddPermissions-directions" ng-if="licenseMissing">
<span class="AddPermissions-directionNumber">
1
</span>
<span class="License-helperText">
<translate>Please click the button below to visit Ansible's website to get a Tower license key.</translate>
</span>
</div>
<button class="License-downloadLicenseButton btn btn-primary" ng-if="licenseMissing" ng-click="downloadLicense()">
<translate>Request License</translate>
</button>
<div class="AddPermissions-directions">
<span class="AddPermissions-directionNumber" ng-if="licenseMissing">
2
</span>
<span class="License-helperText">
<translate>Choose your license file, agree to the End User License Agreement, and click submit.</translate>
<translate>Upload a Red Hat Subscription Manifest containing your subscription. To generate your subscription manifest, go to <a href="https://access.redhat.com/management/subscription_allocations" target="_blank">subscription allocations</a> on the Red Hat Customer Portal.</translate>
</span>
</div>
<div class="License-subTitleText">
<span class="Form-requiredAsterisk">*</span>
<translate>License</translate>
<translate>Red Hat Subscription Manifest</translate>
<a aria-label="{{'Show help text' | translate}}" id="subscription-manifest-popover" href="" aw-pop-over="A subscription manifest is an export of a Red Hat Subscription. To generate a subscription manifest, go to <a href=https://access.redhat.com/management/subscription_allocations target=_blank>access.redhat.com</a>.<br>For more information, see the <a href=https://docs.ansible.com/ansible-tower/latest/html/userguide/import_license.html>User Guide</a>.</translate>" data-placement="top" data-container="body" over-title="Subscription Manifest" class="help-link">
<i class="fa fa-question-circle"></i>
</a>
</div>
<div class="License-helperText License-licenseStepHelp" translate>Upload a license file</div>
<div class="License-filePicker">
<span class="btn btn-primary" ng-click="fakeClick()" ng-disabled="!user_is_superuser || (rhCreds.username && rhCreds.username.length > 0) || (rhCreds.password && rhCreds.password.length > 0)" translate>Browse</span>
<span class="btn btn-primary" ng-click="fakeClick()" ng-disabled="!user_is_superuser || (subscriptionCreds.username && subscriptionCreds.username.length > 0) || (subscriptionCreds.password && subscriptionCreds.password.length > 0)" translate>Browse</span>
<span class="License-fileName" ng-class="{'License-helperText' : fileName == 'No file selected.'}">{{fileName|translate}}</span>
<input id="License-file" class="form-control" type="file" file-on-change="getKey"/>
</div>
@ -125,12 +132,12 @@
<div class="d-block w-100">
<div class="AddPermissions-directions">
<span class="License-helperText">
<translate>Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM.</translate>
<translate>Provide your Red Hat or Red Hat Satellite credentials below and you can choose from a list of your available subscriptions. The credentials you use will be stored for future use in retrieving renewal or expanded subscriptions.</translate>
</span>
</div>
<div class="License-rhCredField">
<label class="License-label d-block" translate>USERNAME</label>
<input class="form-control Form-textInput" type="text" ng-model="rhCreds.username" ng-disabled="!user_is_superuser || newLicense.file" />
<input class="form-control Form-textInput" type="text" ng-model="subscriptionCreds.username" ng-disabled="!user_is_superuser || newLicense.file" />
</div>
<div class="License-rhCredField">
<label class="License-label d-block" translate>PASSWORD</label>
@ -143,11 +150,11 @@
</span>
</div>
<div class="input-group" ng-if="!showPlaceholderPassword">
<input id="rh-password" class="form-control Form-textInput" type="password" ng-model="rhCreds.password" ng-disabled="!user_is_superuser || newLicense.file" />
<input id="rh-password" class="form-control Form-textInput" type="password" ng-model="subscriptionCreds.password" ng-disabled="!user_is_superuser || newLicense.file" />
</div>
</div>
<div class="License-getLicensesButton">
<span ng-click="lookupLicenses()" class="btn btn-primary" ng-disabled="!rhCreds.username || !rhCreds.password" translate>GET LICENSES</button>
<span ng-click="lookupLicenses()" class="btn btn-primary" ng-disabled="!subscriptionCreds.username || !subscriptionCreds.password" translate>GET SUBSCRIPTIONS</button>
</div>
<div ng-if="selectedLicense.fullLicense">
<div class="at-RowItem-label" translate>
@ -158,6 +165,14 @@
</div>
</div>
</div>
<div class="License-helperText">
<span class="AddPermissions-directionNumber" ng-if="licenseMissing">
3
</span>
<span class="License-helperText">
<translate>Agree to the End User License Agreement, and click submit.</translate>
</span>
</div>
<div class="License-subTitleText">
<span class="Form-requiredAsterisk">*</span>
<translate>End User License Agreement</translate>
@ -200,7 +215,7 @@
<span ng-show="success == true" class="License-greenText License-submit--success pull-right" translate>Save successful!</span>
</div>
<div>
<button ng-click="submit()" class="btn btn-success pull-right" ng-disabled="(!newLicense.file && !selectedLicense.fullLicense) || (newLicense.file && newLicense.file.license_key == null) || newLicense.eula == null || !user_is_superuser" translate>Submit</button>
<button ng-click="submit()" class="btn btn-success pull-right" ng-disabled="(!newLicense.manifest && !selectedLicense.fullLicense) || newLicense.eula == null || !user_is_superuser" translate>Submit</button>
</div>
</div>
</div>
@ -223,12 +238,12 @@
<div class="Modal-body ng-binding">
<div class="License-modalBody">
<form>
<div class="License-modalRow" ng-repeat="license in rhLicenses track by license.license_key">
<div class="License-modalRow" ng-repeat="license in rhLicenses track by license.pool_id">
<div class="License-modalRowRadio">
<input type="radio" id="license-{{license.license_key}}" ng-model="selectedLicense.modalKey" value="{{license.license_key}}" />
<input type="radio" id="license-{{license.pool_id}}" ng-model="selectedLicense.modalPoolId" value="{{license.pool_id}}"/>
</div>
<div class="License-modalRowDetails">
<label for="license-{{license.license_key}}" class="License-modalRowDetailsLabel">
<label for="license-{{license.pool_id}}" class="License-modalRowDetailsLabel">
<div class="License-modalRowDetailsRow">
<div class="License-trialTag" ng-if="license.trial" translate>
Trial
@ -260,7 +275,7 @@
<button
ng-click="confirmLicenseSelection()"
class="btn Modal-footerButton btn-success"
ng-disabled="!selectedLicense.modalKey"
ng-disabled="!selectedLicense.modalPoolId"
translate
>
SELECT

View File

@ -15,7 +15,7 @@ export default {
controller: 'licenseController',
data: {},
ncyBreadcrumb: {
label: N_('LICENSE')
label: N_('SUBSCRIPTION')
},
onEnter: ['$state', 'ConfigService', (state, configService) => {
return configService.getConfig()
@ -43,20 +43,20 @@ export default {
});
}
],
rhCreds: ['Rest', 'GetBasePath', function(Rest, GetBasePath) {
subscriptionCreds: ['Rest', 'GetBasePath', function(Rest, GetBasePath) {
Rest.setUrl(`${GetBasePath('settings')}system/`);
return Rest.get()
.then(({data}) => {
const rhCreds = {};
if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") {
rhCreds.REDHAT_USERNAME = data.REDHAT_USERNAME;
const subscriptionCreds = {};
if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") {
subscriptionCreds.SUBSCRIPTIONS_USERNAME = data.SUBSCRIPTIONS_USERNAME;
}
if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") {
rhCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD;
if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") {
subscriptionCreds.SUBSCRIPTIONS_PASSWORD = data.SUBSCRIPTIONS_PASSWORD;
}
return rhCreds;
return subscriptionCreds;
}).catch(() => {
return {};
});

View File

@ -29,30 +29,10 @@ export default ['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', '$q', 'Con
}
};
if (config.analytics_status === 'detailed') {
this.setDetailed(options, config);
} else if (config.analytics_status === 'anonymous') {
this.setAnonymous(options);
}
return options;
},
// Detailed mode sends:
// VisitorId: userid+hash of license_key
// AccountId: hash of license_key from license
setDetailed: function(options, config) {
// config.deployment_id is a hash of the tower license_key
options.visitor.id = $rootScope.current_user.id + '@' + config.deployment_id;
options.account.id = config.deployment_id;
},
// Anonymous mode sends:
// VisitorId: <hardcoded id that is the same across all anonymous>
// AccountId: <hardcoded id that is the same across all anonymous>
setAnonymous: function (options) {
options.visitor.id = 0;
options.account.id = "tower.ansible.com";
return options;
},
setRole: function(options) {

View File

@ -62,7 +62,7 @@ export default
getSubscriptions: function(username, password) {
Rest.setUrl(`${GetBasePath('config')}subscriptions`);
return Rest.post({ rh_username: username, rh_password: password} );
return Rest.post({ subscriptions_username: username, subscriptions_password: password} );
}
};
}

View File

@ -5070,7 +5070,7 @@ msgid "Provide environment variables to pass to the custom inventory script."
msgstr ""
#: client/src/license/license.partial.html:128
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM."
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS &gt; SYSTEM."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:374

View File

@ -5179,8 +5179,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to
msgstr "Indique la URL, el nombre cifrado o id del inventario remoto de Tower para importarlos."
#: client/src/license/license.partial.html:128
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM."
msgstr "Proporcione sus credenciales de cliente de Red Hat y podrá elegir de una lista de sus licencias disponibles. Las credenciales que utilice se almacenarán para su uso futuro en la recuperación de las licencias de renovación o ampliadas. Puede actualizarlas o eliminarlas en CONFIGURACIÓN > SISTEMA."
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS &gt; SYSTEM."
msgstr "Proporcione sus credenciales de cliente de Red Hat y podrá elegir de una lista de sus licencias disponibles. Las credenciales que utilice se almacenarán para su uso futuro en la recuperación de las licencias de renovación o ampliadas. Puede actualizarlas o eliminarlas en CONFIGURACIÓN &gt; SISTEMA."
#: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382

View File

@ -5185,8 +5185,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to
msgstr "Fournir le nom encodé de l'URL ou d'id de l'inventaire distant de Tower à importer."
#: client/src/license/license.partial.html:128
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM."
msgstr "Fournissez vos informations didentification client Red Hat et choisissez parmi une liste de licences disponibles pour vous. Les informations d'identification que vous utilisez seront stockées pour une utilisation ultérieure lors de la récupération des licences renouvelées ou étendues. Vous pouvez les mettre à jour ou les supprimer dans PARAMÈTRES > SYSTÈME."
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS &gt; SYSTEM."
msgstr "Fournissez vos informations didentification client Red Hat et choisissez parmi une liste de licences disponibles pour vous. Les informations d'identification que vous utilisez seront stockées pour une utilisation ultérieure lors de la récupération des licences renouvelées ou étendues. Vous pouvez les mettre à jour ou les supprimer dans PARAMÈTRES &gt; SYSTÈME."
#: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382

View File

@ -5102,8 +5102,8 @@ msgid "Provide environment variables to pass to the custom inventory script."
msgstr "カスタムインベントリースクリプトに渡す環境変数を指定します。"
#: client/src/license/license.partial.html:128
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM."
msgstr "Red Hat の顧客認証情報を指定して、利用可能なライセンス一覧から選択してください。使用した認証情報は、今後、ライセンスの更新や延長情報を取得する時に利用できるように保存されます。設定 > システムでこの情報は更新または削除できます。"
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS &gt; SYSTEM."
msgstr "Red Hat の顧客認証情報を指定して、利用可能なライセンス一覧から選択してください。使用した認証情報は、今後、ライセンスの更新や延長情報を取得する時に利用できるように保存されます。設定 &gt; システムでこの情報は更新または削除できます。"
#: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382

View File

@ -5183,8 +5183,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to
msgstr "Voer de URL, versleutelde naam of ID of de externe inventaris in die geïmporteerd moet worden."
#: client/src/license/license.partial.html:128
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM."
msgstr "Geef uw Red Hat-klantengegevens door en u kunt kiezen uit een lijst met beschikbare licenties. De toegangsgegevens die u gebruikt, worden opgeslagen voor toekomstig gebruik bij het ophalen van verlengingen of uitbreidingen van licenties. U kunt deze updaten of verwijderen in INSTELLINGEN > SYSTEEM."
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS &gt; SYSTEM."
msgstr "Geef uw Red Hat-klantengegevens door en u kunt kiezen uit een lijst met beschikbare licenties. De toegangsgegevens die u gebruikt, worden opgeslagen voor toekomstig gebruik bij het ophalen van verlengingen of uitbreidingen van licenties. U kunt deze updaten of verwijderen in INSTELLINGEN &gt; SYSTEEM."
#: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382

View File

@ -5183,8 +5183,8 @@ msgid "Provide the named URL encoded name or id of the remote Tower inventory to
msgstr "提供要导入的远程 Tower 清单的命名 URL 编码名称或 ID。"
#: client/src/license/license.partial.html:128
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS > SYSTEM."
msgstr "提供您的红帽客户凭证,您可以从可用许可证列表中进行选择。您使用的凭证将存储以供将来用于检索续订或扩展许可证。您可以在“设置”>“系统”中更新或删除它们。"
msgid "Provide your Red Hat customer credentials and you can choose from a list of your available licenses. The credentials you use will be stored for future use in retrieving renewal or expanded licenses. You can update or remove them in SETTINGS &gt; SYSTEM."
msgstr "提供您的红帽客户凭证,您可以从可用许可证列表中进行选择。您使用的凭证将存储以供将来用于检索续订或扩展许可证。您可以在“设置”&gt;“系统”中更新或删除它们。"
#: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382

View File

@ -7,7 +7,7 @@ describe('Controller: LicenseController', () => {
ConfigService,
ProcessErrors,
config,
rhCreds;
subscriptionCreds;
beforeEach(angular.mock.module('awApp'));
beforeEach(angular.mock.module('license', ($provide) => {
@ -23,7 +23,7 @@ describe('Controller: LicenseController', () => {
version: '3.1.0-devel'
};
rhCreds = {
subscriptionCreds = {
password: '$encrypted$',
username: 'foo',
}
@ -33,21 +33,21 @@ describe('Controller: LicenseController', () => {
$provide.value('ConfigService', ConfigService);
$provide.value('ProcessErrors', ProcessErrors);
$provide.value('config', config);
$provide.value('rhCreds', rhCreds);
$provide.value('subscriptionCreds', subscriptionCreds);
}));
beforeEach(angular.mock.inject( ($rootScope, $controller, _ConfigService_, _ProcessErrors_, _config_, _rhCreds_) => {
beforeEach(angular.mock.inject( ($rootScope, $controller, _ConfigService_, _ProcessErrors_, _config_, _subscriptionCreds_) => {
scope = $rootScope.$new();
ConfigService = _ConfigService_;
ProcessErrors = _ProcessErrors_;
config = _config_;
rhCreds = _rhCreds_;
subscriptionCreds = _subscriptionCreds_;
LicenseController = $controller('licenseController', {
$scope: scope,
ConfigService: ConfigService,
ProcessErrors: ProcessErrors,
config: config,
rhCreds: rhCreds
subscriptionCreds: subscriptionCreds
});
}));

File diff suppressed because it is too large Load Diff

View File

@ -49,7 +49,7 @@
"jest-websocket-mock": "^2.0.2",
"mock-socket": "^9.0.3",
"prettier": "^1.18.2",
"react-scripts": "^3.4.3"
"react-scripts": "^3.4.4"
},
"scripts": {
"start": "PORT=3001 HTTPS=true DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start",

View File

@ -4,6 +4,7 @@ import Config from './models/Config';
import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials';
import Dashboard from './models/Dashboard';
import Groups from './models/Groups';
import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups';
@ -42,6 +43,7 @@ const ConfigAPI = new Config();
const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes();
const CredentialsAPI = new Credentials();
const DashboardAPI = new Dashboard();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
@ -81,6 +83,7 @@ export {
CredentialInputSourcesAPI,
CredentialTypesAPI,
CredentialsAPI,
DashboardAPI,
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,

View File

@ -20,10 +20,38 @@ class Credentials extends Base {
return this.http.options(`${this.baseUrl}${id}/access_list/`);
}
readInputSources(id, params) {
return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
params,
});
readInputSources(id) {
const maxRequests = 5;
let requestCounter = 0;
const fetchInputSources = async (pageNo = 1, inputSources = []) => {
try {
requestCounter++;
const { data } = await this.http.get(
`${this.baseUrl}${id}/input_sources/`,
{
params: {
page: pageNo,
page_size: 200,
},
}
);
if (data?.next && requestCounter <= maxRequests) {
return fetchInputSources(
pageNo + 1,
inputSources.concat(data.results)
);
}
return Promise.resolve({
data: {
results: inputSources.concat(data.results),
},
});
} catch (error) {
return Promise.reject(error);
}
};
return fetchInputSources();
}
test(id, data) {

View File

@ -0,0 +1,16 @@
import Base from '../Base';
class Dashboard extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/dashboard/';
}
readJobGraph(params) {
return this.http.get(`${this.baseUrl}graphs/jobs/`, {
params,
});
}
}
export default Dashboard;

View File

@ -35,7 +35,24 @@ class Groups extends Base {
}
readChildren(id, params) {
return this.http.get(`${this.baseUrl}${id}/children/`, params);
return this.http.get(`${this.baseUrl}${id}/children/`, { params });
}
associateChildGroup(id, childId) {
return this.http.post(`${this.baseUrl}${id}/children/`, { id: childId });
}
disassociateChildGroup(id, childId) {
return this.http.post(`${this.baseUrl}${id}/children/`, {
disassociate: id,
id: childId,
});
}
readPotentialGroups(id, params) {
return this.http.get(`${this.baseUrl}${id}/potential_children/`, {
params,
});
}
}

View File

@ -24,6 +24,12 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
return this.http.options(`${this.baseUrl}${id}/teams/`);
}
readGalaxyCredentials(id, params) {
return this.http.get(`${this.baseUrl}${id}/galaxy_credentials/`, {
params,
});
}
createUser(id, data) {
return this.http.post(`${this.baseUrl}${id}/users/`, data);
}
@ -48,6 +54,19 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
{ id: notificationId, disassociate: true }
);
}
associateGalaxyCredential(resourceId, credentialId) {
return this.http.post(`${this.baseUrl}${resourceId}/galaxy_credentials/`, {
id: credentialId,
});
}
disassociateGalaxyCredential(resourceId, credentialId) {
return this.http.post(`${this.baseUrl}${resourceId}/galaxy_credentials/`, {
id: credentialId,
disassociate: true,
});
}
}
export default Organizations;

View File

@ -77,21 +77,28 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
readNotificationTemplatesApprovals(id, params) {
return this.http.get(
`${this.baseUrl}${id}/notification_templates_approvals/`,
{ params }
{
params,
}
);
}
associateNotificationTemplatesApprovals(resourceId, notificationId) {
return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
{ id: notificationId }
{
id: notificationId,
}
);
}
disassociateNotificationTemplatesApprovals(resourceId, notificationId) {
return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_approvals/`,
{ id: notificationId, disassociate: true }
{
id: notificationId,
disassociate: true,
}
);
}
}

View File

@ -1,26 +1,27 @@
import React, { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import React, { useCallback, useEffect, useState, useContext } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { Button, DropdownItem } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../util/useRequest';
import { InventoriesAPI } from '../../api';
import { InventoriesAPI, CredentialTypesAPI } from '../../api';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard';
import { KebabifiedContext } from '../../contexts/Kebabified';
import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({
onClose,
adHocItems,
itemId,
i18n,
moduleOptions,
credentialTypeId,
}) {
function AdHocCommands({ adHocItems, i18n, hasListItems }) {
const history = useHistory();
const { id } = useParams();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
@ -28,26 +29,51 @@ function AdHocCommands({
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
];
useEffect(() => {
if (isKebabified) {
onKebabModalChange(isWizardOpen);
}
}, [isKebabified, isWizardOpen, onKebabModalChange]);
const {
result: { moduleOptions, credentialTypeId, isAdHocDisabled },
request: fetchData,
error: fetchError,
} = useRequest(
useCallback(async () => {
const [options, cred] = await Promise.all([
InventoriesAPI.readAdHocOptions(id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
moduleOptions: options.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !options.data.actions.POST,
};
}, [id]),
{ moduleOptions: [], isAdHocDisabled: true }
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
isloading: isLaunchLoading,
error,
error: launchError,
request: launchAdHocCommands,
} = useRequest(
useCallback(
async values => {
const { data } = await InventoriesAPI.launchAdHocCommands(
itemId,
values
);
const { data } = await InventoriesAPI.launchAdHocCommands(id, values);
history.push(`/jobs/command/${data.id}/output`);
},
[itemId, history]
[id, history]
)
);
const { dismissError } = useDismissableError(error);
const { error, dismissError } = useDismissableError(
launchError || fetchError
);
const handleSubmit = async values => {
const { credential, ...remainingValues } = values;
@ -64,7 +90,7 @@ function AdHocCommands({
return <ContentLoading />;
}
if (error) {
if (error && isWizardOpen) {
return (
<AlertModal
isOpen={error}
@ -72,31 +98,63 @@ function AdHocCommands({
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
setIsWizardOpen(false);
}}
>
<>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={error} />
</>
{launchError ? (
<>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={error} />
</>
) : (
<ContentError error={error} />
)}
</AlertModal>
);
}
return (
<AdHocCommandsWizard
adHocItems={adHocItems}
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={onClose}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
// render buttons for drop down and for toolbar
// if modal is open render the modal
<>
{isKebabified ? (
<DropdownItem
key="cancel-job"
isDisabled={isAdHocDisabled || !hasListItems}
component="button"
aria-label={i18n._(t`Run Command`)}
onClick={() => setIsWizardOpen(true)}
>
{i18n._(t`Run Command`)}
</DropdownItem>
) : (
<Button
variant="secondary"
aria-label={i18n._(t`Run Command`)}
onClick={() => setIsWizardOpen(true)}
isDisabled={isAdHocDisabled || !hasListItems}
>
{i18n._(t`Run Command`)}
</Button>
)}
{isWizardOpen && (
<AdHocCommandsWizard
adHocItems={adHocItems}
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
)}
</>
);
}
AdHocCommands.propTypes = {
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired,
hasListItems: PropTypes.bool.isRequired,
};
export default withI18n()(AdHocCommands);

View File

@ -10,7 +10,12 @@ import AdHocCommands from './AdHocCommands';
jest.mock('../../api/models/CredentialTypes');
jest.mock('../../api/models/Inventories');
jest.mock('../../api/models/Credentials');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
}),
}));
const credentials = [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
@ -18,10 +23,7 @@ const credentials = [
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
];
const moduleOptions = [
['command', 'command'],
['shell', 'shell'],
];
const adHocItems = [
{
name: 'Inventory 1 Org 0',
@ -30,6 +32,26 @@ const adHocItems = [
];
describe('<AdHocCommands />', () => {
beforeEach(() => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
});
let wrapper;
afterEach(() => {
wrapper.unmount();
@ -39,19 +61,45 @@ describe('<AdHocCommands />', () => {
test('mounts successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
<AdHocCommands adHocItems={adHocItems} hasListItems />
);
});
expect(wrapper.find('AdHocCommands').length).toBe(1);
});
test('should open the wizard', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
);
});
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
});
test('should submit properly', async () => {
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
CredentialsAPI.read.mockResolvedValue({
@ -62,17 +110,13 @@ describe('<AdHocCommands />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
<AdHocCommands adHocItems={adHocItems} hasListItems />
);
});
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
@ -174,17 +218,13 @@ describe('<AdHocCommands />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
css="margin-right: 20px"
onClose={() => {}}
credentialTypeId={1}
itemId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
<AdHocCommands adHocItems={adHocItems} hasListItems />
);
});
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
@ -237,4 +277,69 @@ describe('<AdHocCommands />', () => {
await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
});
test('should disable run command button due to permissions', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run Command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
});
test('should disable run command button due to lack of list items', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems={false} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run Command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
});
test('should open alert modal when error on fetching data', async () => {
InventoriesAPI.readAdHocOptions.mockRejectedValue(
new Error({
response: {
config: {
method: 'options',
url: '/api/v2/inventories/1/',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands adHocItems={adHocItems} hasListItems />
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
});

View File

@ -134,7 +134,7 @@ const FormikApp = withFormik({
FormikApp.propTypes = {
onLaunch: PropTypes.func.isRequired,
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
onCloseWizard: PropTypes.func.isRequired,
credentialTypeId: PropTypes.number.isRequired,

View File

@ -17,6 +17,10 @@ const verbosityOptions = [
{ value: '3', key: '3', label: '3 (Debug)' },
{ value: '4', key: '4', label: '4 (Connection Debug)' },
];
const moduleOptions = [
['command', 'command'],
['shell', 'shell'],
];
const adHocItems = [
{ name: 'Inventory 1' },
{ name: 'Inventory 2' },
@ -31,7 +35,7 @@ describe('<AdHocCommandsWizard/>', () => {
<AdHocCommandsWizard
adHocItems={adHocItems}
onLaunch={onLaunch}
moduleOptions={[]}
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
onCloseWizard={() => {}}
credentialTypeId={1}

View File

@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import { useField } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core';
import { CredentialsAPI } from '../../api';
import { FieldTooltip } from '../FormField';
import Popover from '../Popover';
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
import useRequest from '../../util/useRequest';
@ -72,7 +72,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
}
helperTextInvalid={credentialMeta.error}
labelIcon={
<FieldTooltip
<Popover
content={i18n._(
t`Select the credential you want to use when accessing the remote hosts to run the command. Choose the credential containing the username and SSH key or password that Ansible will need to log into the remote hosts.`
)}

View File

@ -9,13 +9,14 @@ import styled from 'styled-components';
import { BrandName } from '../../variables';
import AnsibleSelect from '../AnsibleSelect';
import FormField, { FieldTooltip } from '../FormField';
import FormField from '../FormField';
import { VariablesField } from '../CodeMirrorInput';
import {
FormColumnLayout,
FormFullWidthLayout,
FormCheckboxLayout,
} from '../FormLayout';
import Popover from '../Popover';
import { required } from '../../util/validators';
const TooltipWrapper = styled.div`
@ -58,7 +59,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
<FormFullWidthLayout>
<FormGroup
fieldId="module_name"
aria-label={i18n._(t`Module`)}
aria-label={i18n._(t`select module`)}
label={i18n._(t`Module`)}
isRequired
helperTextInvalid={moduleNameMeta.error}
@ -68,7 +69,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
: 'error'
}
labelIcon={
<FieldTooltip
<Popover
content={i18n._(
t`These are the modules that ${brandName} supports running commands against.`
)}
@ -109,7 +110,6 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
label={i18n._(t`Arguments`)}
validated={isValid ? 'default' : 'error'}
onBlur={() => argumentsHelpers.setTouched(true)}
placeholder={i18n._(t`Enter arguments`)}
isRequired={
moduleNameField.value === 'command' ||
moduleNameField.value === 'shell'
@ -136,7 +136,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
/>
<FormGroup
fieldId="verbosity"
aria-label={i18n._(t`Verbosity`)}
aria-label={i18n._(t`select verbosity`)}
label={i18n._(t`Verbosity`)}
isRequired
validated={
@ -146,7 +146,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
}
helperTextInvalid={verbosityMeta.error}
labelIcon={
<FieldTooltip
<Popover
content={i18n._(
t`These are the verbosity levels for standard out of the command run that are supported.`
)}
@ -211,7 +211,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
label={i18n._(t`Show changes`)}
aria-label={i18n._(t`Show changes`)}
labelIcon={
<FieldTooltip
<Popover
content={i18n._(
t`If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansibles --diff mode.`
)}
@ -238,7 +238,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
<span>
{i18n._(t`Enable privilege escalation`)}
&nbsp;
<FieldTooltip
<Popover
content={
<p>
{i18n._(t`Enables creation of a provisioning
@ -316,7 +316,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
}
AdHocDetailsStep.propTypes = {
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
};

View File

@ -1,15 +1,9 @@
import React, { useState, useRef, useEffect, Fragment } from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import {
Dropdown,
DropdownPosition,
DropdownItem,
} from '@patternfly/react-core';
import { Dropdown, DropdownPosition } from '@patternfly/react-core';
import { ToolbarAddButton } from '../PaginatedDataList';
import { toTitleCase } from '../../util/strings';
import { useKebabifiedMenu } from '../../contexts/Kebabified';
function AddDropDownButton({ dropdownItems, i18n }) {
@ -31,15 +25,7 @@ function AddDropDownButton({ dropdownItems, i18n }) {
}, [isKebabified]);
if (isKebabified) {
return (
<Fragment>
{dropdownItems.map(item => (
<DropdownItem key={item.url} component={Link} to={item.url}>
{toTitleCase(`${i18n._(t`Add`)} ${item.label}`)}
</DropdownItem>
))}
</Fragment>
);
return <Fragment>{dropdownItems}</Fragment>;
}
return (
@ -50,31 +36,19 @@ function AddDropDownButton({ dropdownItems, i18n }) {
position={DropdownPosition.right}
toggle={
<ToolbarAddButton
aria-label={i18n._(t`Add`)}
showToggleIndicator
onClick={() => setIsOpen(!isOpen)}
/>
}
dropdownItems={dropdownItems.map(item => (
<Link
className="pf-c-dropdown__menu-item"
key={item.url}
to={item.url}
>
{item.label}
</Link>
))}
dropdownItems={dropdownItems}
/>
</div>
);
}
AddDropDownButton.propTypes = {
dropdownItems: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})
).isRequired,
dropdownItems: PropTypes.arrayOf(PropTypes.element.isRequired).isRequired,
};
export { AddDropDownButton as _AddDropDownButton };

View File

@ -1,14 +1,12 @@
import React from 'react';
import { DropdownItem } from '@patternfly/react-core';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AddDropDownButton from './AddDropDownButton';
describe('<AddDropDownButton />', () => {
const dropdownItems = [
{
key: 'inventory',
label: 'Inventory',
url: `inventory/inventory/add/`,
},
<DropdownItem key="add">Add</DropdownItem>,
<DropdownItem key="route">Route</DropdownItem>,
];
test('should be closed initially', () => {
const wrapper = mountWithContexts(
@ -23,7 +21,7 @@ describe('<AddDropDownButton />', () => {
);
wrapper.find('button').simulate('click');
expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(true);
expect(wrapper.find('Link')).toHaveLength(dropdownItems.length);
expect(wrapper.find('DropdownItem')).toHaveLength(dropdownItems.length);
});
test('should close when button re-clicked', () => {

View File

@ -118,8 +118,8 @@ class PageHeaderToolbar extends Component {
key="user"
href={
loggedInUser
? `#/users/${loggedInUser.id}/details`
: '#/home'
? `/next/users/${loggedInUser.id}/details`
: '/next/home'
}
>
{i18n._(t`User Details`)}

View File

@ -12,7 +12,7 @@ import {
import { useField } from 'formik';
import { FormGroup } from '@patternfly/react-core';
import CodeMirrorInput from './CodeMirrorInput';
import { FieldTooltip } from '../FormField';
import Popover from '../Popover';
function CodeMirrorField({
id,
@ -37,7 +37,7 @@ function CodeMirrorField({
isRequired={isRequired}
validated={isValid ? 'default' : 'error'}
label={label}
labelIcon={<FieldTooltip content={tooltip} />}
labelIcon={<Popover content={tooltip} />}
>
<CodeMirrorInput
id={id}

View File

@ -4,7 +4,7 @@ import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle';
import DetailPopover from '../DetailPopover';
import Popover from '../Popover';
import {
yamlToJson,
jsonToYaml,
@ -69,7 +69,7 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
{label}
</span>
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
<Popover header={label} content={helpText} id={dataCy} />
)}
</div>
</SplitItem>
@ -122,9 +122,13 @@ VariablesDetail.propTypes = {
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired,
rows: number,
dataCy: string,
helpText: string,
};
VariablesDetail.defaultProps = {
rows: null,
dataCy: '',
helpText: '',
};
export default VariablesDetail;

View File

@ -5,10 +5,11 @@ import { t } from '@lingui/macro';
import { useField } from 'formik';
import styled from 'styled-components';
import { Split, SplitItem } from '@patternfly/react-core';
import { CheckboxField, FieldTooltip } from '../FormField';
import { CheckboxField } from '../FormField';
import MultiButtonToggle from '../MultiButtonToggle';
import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
import CodeMirrorInput from './CodeMirrorInput';
import Popover from '../Popover';
import { JSON_MODE, YAML_MODE } from './constants';
const FieldHeader = styled.div`
@ -43,7 +44,7 @@ function VariablesField({
<label htmlFor={id} className="pf-c-form__label">
<span className="pf-c-form__label-text">{label}</span>
</label>
{tooltip && <FieldTooltip content={tooltip} />}
{tooltip && <Popover content={tooltip} id={id} />}
</SplitItem>
<SplitItem>
<MultiButtonToggle

View File

@ -1,7 +1,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import VariablesField from './VariablesField';
describe('VariablesField', () => {
@ -11,7 +11,7 @@ describe('VariablesField', () => {
it('should render code mirror input', () => {
const value = '---\n';
const wrapper = mount(
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }}>
{() => (
<VariablesField id="the-field" name="variables" label="Variables" />
@ -24,7 +24,7 @@ describe('VariablesField', () => {
it('should render yaml/json toggles', async () => {
const value = '---\n';
const wrapper = mount(
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }}>
{() => (
<VariablesField id="the-field" name="variables" label="Variables" />
@ -52,7 +52,7 @@ describe('VariablesField', () => {
it('should set Formik error if yaml is invalid', async () => {
const value = '---\nfoo bar\n';
const wrapper = mount(
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }}>
{() => (
<VariablesField id="the-field" name="variables" label="Variables" />
@ -71,7 +71,7 @@ describe('VariablesField', () => {
});
it('should render tooltip', () => {
const value = '---\n';
const wrapper = mount(
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }}>
{() => (
<VariablesField
@ -83,13 +83,13 @@ describe('VariablesField', () => {
)}
</Formik>
);
expect(wrapper.find('Popover').length).toBe(1);
expect(wrapper.find('Popover[data-cy="the-field"]').length).toBe(1);
});
it('should submit value through Formik', async () => {
const value = '---\nfoo: bar\n';
const handleSubmit = jest.fn();
const wrapper = mount(
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }} onSubmit={handleSubmit}>
{formik => (
<form onSubmit={formik.handleSubmit}>
@ -116,7 +116,7 @@ describe('VariablesField', () => {
it('should initialize to JSON if value is JSON', async () => {
const value = '{"foo": "bar"}';
const wrapper = mount(
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }} onSubmit={jest.fn()}>
{formik => (
<form onSubmit={formik.handleSubmit}>

View File

@ -62,6 +62,7 @@ function DataListToolbar({
id={`${qsConfig.namespace}-list-toolbar`}
clearAllFilters={clearAllFilters}
collapseListedFiltersBreakpoint="lg"
clearFiltersButtonText={i18n._(t`Clear all filters`)}
>
<ToolbarContent>
{showSelectAll && (

View File

@ -1,6 +1,8 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import DataListToolbar from './DataListToolbar';
import AddDropDownButton from '../AddDropDownButton/AddDropDownButton';
describe('<DataListToolbar />', () => {
let toolbar;
@ -313,4 +315,44 @@ describe('<DataListToolbar />', () => {
search.prop('columns').filter(col => col.key === 'advanced').length
).toBe(1);
});
test('should properly render toolbar buttons when in advanced search mode', async () => {
const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }];
const sortColumns = [{ name: 'Name', key: 'name' }];
const newToolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
additionalControls={[
<AddDropDownButton
dropdownItems={[
<div key="add container" aria-label="add container">
Add Contaner
</div>,
<div key="add instance group" aria-label="add instance group">
Add Instance Group
</div>,
]}
/>,
]}
/>
);
await act(() =>
newToolbar.find('Search').prop('onShowAdvancedSearch')(true)
);
newToolbar.update();
expect(newToolbar.find('KebabToggle').length).toBe(1);
await act(() => newToolbar.find('KebabToggle').prop('onToggle')(true));
newToolbar.update();
expect(newToolbar.find('div[aria-label="add container"]').length).toBe(1);
expect(newToolbar.find('div[aria-label="add instance group"]').length).toBe(
1
);
});
});

View File

@ -1,17 +1,30 @@
import 'styled-components/macro';
import React from 'react';
import { shape, node, number, oneOf } from 'prop-types';
import { shape, node, number, oneOf, string } from 'prop-types';
import { TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from './Detail';
import CodeMirrorInput from '../CodeMirrorInput';
import Popover from '../Popover';
function CodeDetail({
value,
label,
mode,
rows,
fullHeight,
helpText,
dataCy,
}) {
const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null;
function CodeDetail({ value, label, mode, rows, fullHeight }) {
return (
<>
<DetailName
component={TextListItemVariants.dt}
fullWidth
css="grid-column: 1 / -1"
data-cy={labelCy}
>
<div className="pf-c-form__label">
<span
@ -20,12 +33,16 @@ function CodeDetail({ value, label, mode, rows, fullHeight }) {
>
{label}
</span>
{helpText && (
<Popover header={label} content={helpText} id={dataCy} />
)}
</div>
</DetailName>
<DetailValue
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
data-cy={valueCy}
>
<CodeMirrorInput
mode={mode}
@ -42,11 +59,15 @@ function CodeDetail({ value, label, mode, rows, fullHeight }) {
CodeDetail.propTypes = {
value: shape.isRequired,
label: node.isRequired,
dataCy: string,
helpText: string,
rows: number,
mode: oneOf(['json', 'yaml', 'jinja2']).isRequired,
};
CodeDetail.defaultProps = {
rows: null,
helpText: '',
dataCy: '',
};
export default CodeDetail;

View File

@ -2,7 +2,7 @@ import React from 'react';
import { node, bool, string } from 'prop-types';
import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
import styled from 'styled-components';
import DetailPopover from '../DetailPopover';
import Popover from '../Popover';
const DetailName = styled(({ fullWidth, ...props }) => (
<TextListItem {...props} />
@ -61,9 +61,7 @@ const Detail = ({
id={dataCy}
>
{label}
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
{helpText && <Popover header={label} content={helpText} id={dataCy} />}
</DetailName>
<DetailValue
className={className}

View File

@ -1,51 +0,0 @@
import React, { useState } from 'react';
import { node, string } from 'prop-types';
import { Button as _Button, Popover } from '@patternfly/react-core';
import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const Button = styled(_Button)`
--pf-c-button--PaddingTop: 0;
--pf-c-button--PaddingBottom: 0;
`;
function DetailPopover({ header, content, id }) {
const [showPopover, setShowPopover] = useState(false);
if (!content) {
return null;
}
return (
<Popover
bodyContent={content}
headerContent={header}
hideOnOutsideClick
id={id}
isVisible={showPopover}
shouldClose={() => setShowPopover(false)}
>
<Button
onClick={() => setShowPopover(!showPopover)}
variant="plain"
aria-haspopup="true"
aria-expanded={showPopover}
>
<OutlinedQuestionCircleIcon
onClick={() => setShowPopover(!showPopover)}
/>
</Button>
</Popover>
);
}
DetailPopover.propTypes = {
content: node,
header: node,
id: string,
};
DetailPopover.defaultProps = {
content: null,
header: null,
id: 'detail-popover',
};
export default DetailPopover;

View File

@ -1 +0,0 @@
export { default } from './DetailPopover';

Some files were not shown because too many files have changed in this diff Show More