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
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/build
awx/ui_next/.env.local awx/ui_next/.env.local
rsyslog.pid rsyslog.pid
/tower-license
/tower-license/**
tools/prometheus/data tools/prometheus/data
tools/docker-compose/Dockerfile tools/docker-compose/Dockerfile
@@ -147,3 +145,4 @@ use_dev_supervisor.txt
.idea/* .idea/*
*.unison.tmp *.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>`. 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) ## 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 - 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 **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 - [docker](https://pypi.org/project/docker/) Python module
+ This is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it. + 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. + 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/) - [GNU Make](https://www.gnu.org/software/make/)
- [Git](https://git-scm.com/) Requires Version 1.8.4+ - [Git](https://git-scm.com/) Requires Version 1.8.4+
- Python 3.6+ - Python 3.6+

View File

@@ -214,7 +214,11 @@ requirements_awx_dev:
requirements_collections: requirements_collections:
mkdir -p $(COLLECTION_BASE) 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 requirements: requirements_ansible requirements_awx requirements_collections
@@ -646,9 +650,11 @@ awx/projects:
docker-compose-isolated: 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 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 Development environment
docker-compose: docker-auth awx/projects 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 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 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, get_object_or_400,
decrypt_field, decrypt_field,
get_awx_version, get_awx_version,
get_licenser,
StubLicense
) )
from awx.main.utils.db import get_all_field_names from awx.main.utils.db import get_all_field_names
from awx.main.views import ApiErrorView from awx.main.views import ApiErrorView
@@ -189,7 +187,8 @@ class APIView(views.APIView):
''' '''
Log warning for 400 requests. Add header with elapsed time. 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 # If the URL was rewritten, and we get a 404, we should entirely
# replace the view in the request context with an ApiErrorView() # 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) response = super(APIView, self).finalize_response(request, response, *args, **kwargs)
time_started = getattr(self, 'time_started', None) time_started = getattr(self, 'time_started', None)
response['X-API-Product-Version'] = get_awx_version() 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 response['X-API-Node'] = settings.CLUSTER_HOST_ID
if time_started: if time_started:
time_elapsed = time.time() - self.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 'capability_map' not in self.context:
if hasattr(self, 'polymorphic_base'): if hasattr(self, 'polymorphic_base'):
model = self.polymorphic_base.Meta.model model = self.polymorphic_base.Meta.model
prefetch_list = self.polymorphic_base._capabilities_prefetch prefetch_list = self.polymorphic_base.capabilities_prefetch
else: else:
model = self.Meta.model model = self.Meta.model
prefetch_list = self.capabilities_prefetch prefetch_list = self.capabilities_prefetch
@@ -640,12 +640,9 @@ class EmptySerializer(serializers.Serializer):
class UnifiedJobTemplateSerializer(BaseSerializer): class UnifiedJobTemplateSerializer(BaseSerializer):
# As a base serializer, the capabilities prefetch is not used directly # As a base serializer, the capabilities prefetch is not used directly,
_capabilities_prefetch = [ # instead they are derived from the Workflow Job Template Serializer and the Job Template Serializer, respectively.
'admin', 'execute', capabilities_prefetch = []
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
'organization.workflow_admin']}
]
class Meta: class Meta:
model = UnifiedJobTemplate model = UnifiedJobTemplate
@@ -695,7 +692,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
serializer.polymorphic_base = self serializer.polymorphic_base = self
# capabilities prefetch is only valid for these models # capabilities prefetch is only valid for these models
if isinstance(obj, (JobTemplate, WorkflowJobTemplate)): if isinstance(obj, (JobTemplate, WorkflowJobTemplate)):
serializer.capabilities_prefetch = self._capabilities_prefetch serializer.capabilities_prefetch = serializer_class.capabilities_prefetch
else: else:
serializer.capabilities_prefetch = None serializer.capabilities_prefetch = None
return serializer.to_representation(obj) return serializer.to_representation(obj)
@@ -1333,6 +1330,8 @@ class ProjectOptionsSerializer(BaseSerializer):
scm_type = attrs.get('scm_type', u'') or u'' scm_type = attrs.get('scm_type', u'') or u''
if self.instance and not scm_type: if self.instance and not scm_type:
valid_local_paths.append(self.instance.local_path) 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: if scm_type:
attrs.pop('local_path', None) attrs.pop('local_path', None)
if 'local_path' in attrs and attrs['local_path'] not in valid_local_paths: 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 ?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: The type of job can be filtered with:

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
# Copyright (c) 2018 Ansible, Inc. # Copyright (c) 2018 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
import base64
import json
import logging import logging
import operator import operator
import json
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
@@ -29,8 +30,8 @@ from awx.main.utils import (
get_custom_venv_choices, get_custom_venv_choices,
to_python_boolean, to_python_boolean,
) )
from awx.main.utils.licensing import validate_entitlement_manifest
from awx.api.versioning import reverse, drf_reverse 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.constants import PRIVILEGE_ESCALATION_METHODS
from awx.main.models import ( from awx.main.models import (
Project, Project,
@@ -178,7 +179,7 @@ class ApiV2PingView(APIView):
class ApiV2SubscriptionView(APIView): class ApiV2SubscriptionView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
name = _('Configuration') name = _('Subscriptions')
swagger_topic = 'System Configuration' swagger_topic = 'System Configuration'
def check_permissions(self, request): def check_permissions(self, request):
@@ -189,18 +190,18 @@ class ApiV2SubscriptionView(APIView):
def post(self, request): def post(self, request):
from awx.main.utils.common import get_licenser from awx.main.utils.common import get_licenser
data = request.data.copy() data = request.data.copy()
if data.get('rh_password') == '$encrypted$': if data.get('subscriptions_password') == '$encrypted$':
data['rh_password'] = settings.REDHAT_PASSWORD data['subscriptions_password'] = settings.SUBSCRIPTIONS_PASSWORD
try: 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): with set_environ(**settings.AWX_TASK_ENV):
validated = get_licenser().validate_rh(user, pw) validated = get_licenser().validate_rh(user, pw)
if user: if user:
settings.REDHAT_USERNAME = data['rh_username'] settings.SUBSCRIPTIONS_USERNAME = data['subscriptions_username']
if pw: if pw:
settings.REDHAT_PASSWORD = data['rh_password'] settings.SUBSCRIPTIONS_PASSWORD = data['subscriptions_password']
except Exception as exc: except Exception as exc:
msg = _("Invalid License") msg = _("Invalid Subscription")
if ( if (
isinstance(exc, requests.exceptions.HTTPError) and isinstance(exc, requests.exceptions.HTTPError) and
getattr(getattr(exc, 'response', None), 'status_code', None) == 401 getattr(getattr(exc, 'response', None), 'status_code', None) == 401
@@ -213,13 +214,63 @@ class ApiV2SubscriptionView(APIView):
elif isinstance(exc, (ValueError, OSError)) and exc.args: elif isinstance(exc, (ValueError, OSError)) and exc.args:
msg = exc.args[0] msg = exc.args[0]
else: else:
logger.exception(smart_text(u"Invalid license submitted."), logger.exception(smart_text(u"Invalid subscription submitted."),
extra=dict(actor=request.user.username)) extra=dict(actor=request.user.username))
return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST)
return Response(validated) 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): class ApiV2ConfigView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
@@ -234,15 +285,11 @@ class ApiV2ConfigView(APIView):
def get(self, request, format=None): def get(self, request, format=None):
'''Return various sitewide configuration settings''' '''Return various sitewide configuration settings'''
if request.user.is_superuser or request.user.is_system_auditor: from awx.main.utils.common import get_licenser
license_data = get_license(show_key=True) license_data = get_licenser().validate()
else:
license_data = get_license(show_key=False)
if not license_data.get('valid_key', False): if not license_data.get('valid_key', False):
license_data = {} 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' 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) return Response(data)
def post(self, request): def post(self, request):
if not isinstance(request.data, dict): 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: if "eula_accepted" not in request.data:
return Response({"error": _("Missing 'eula_accepted' property")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("Missing 'eula_accepted' property")}, status=status.HTTP_400_BAD_REQUEST)
try: try:
@@ -300,25 +348,47 @@ class ApiV2ConfigView(APIView):
logger.info(smart_text(u"Invalid JSON submitted for license."), logger.info(smart_text(u"Invalid JSON submitted for license."),
extra=dict(actor=request.user.username)) extra=dict(actor=request.user.username))
return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST)
try:
from awx.main.utils.common import get_licenser from awx.main.utils.common import get_licenser
license_data = json.loads(data_actual) license_data = json.loads(data_actual)
license_data_validated = get_licenser(**license_data).validate() if 'license_key' in license_data:
except Exception: return Response({"error": _('Legacy license submitted. A subscription manifest is now required.')}, status=status.HTTP_400_BAD_REQUEST)
logger.warning(smart_text(u"Invalid license submitted."), if 'manifest' in license_data:
extra=dict(actor=request.user.username)) try:
return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) 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 the license is valid, write it to the database.
if license_data_validated['valid_key']: if license_data_validated['valid_key']:
settings.LICENSE = license_data
if not settings_registry.is_setting_read_only('TOWER_URL_BASE'): if not settings_registry.is_setting_read_only('TOWER_URL_BASE'):
settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host()) settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host())
return Response(license_data_validated) 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)) 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): def delete(self, request):
try: try:

View File

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

View File

@@ -1,18 +1,14 @@
# Copyright (c) 2016 Ansible, Inc. # Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
__all__ = ['get_license'] __all__ = ['get_license']
def _get_validated_license_data(): def _get_validated_license_data():
from awx.main.utils.common import get_licenser from awx.main.utils import get_licenser
return get_licenser().validate() return get_licenser().validate()
def get_license(show_key=False): def get_license():
"""Return a dictionary representing the active license on this Tower instance.""" """Return a dictionary representing the active license on this Tower instance."""
license_data = _get_validated_license_data() return _get_validated_license_data()
if not show_key:
license_data.pop('license_key', None)
return 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): def get_cache_id_key(self, key):
return '{}_ID'.format(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 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): def config(since, **kwargs):
license_info = get_license(show_key=False) license_info = get_license()
install_type = 'traditional' install_type = 'traditional'
if os.environ.get('container') == 'oci': if os.environ.get('container') == 'oci':
install_type = 'openshift' install_type = 'openshift'

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
# Python # Python
import json
import logging import logging
import os
# Django # Django
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -13,6 +11,7 @@ from rest_framework.fields import FloatField
# Tower # Tower
from awx.conf import fields, register, register_validate from awx.conf import fields, register, register_validate
logger = logging.getLogger('awx.main.conf') logger = logging.getLogger('awx.main.conf')
register( 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( register(
'LICENSE', 'LICENSE',
field_class=fields.DictField, field_class=fields.DictField,
default=_load_default_license_from_file, default=lambda: {},
label=_('License'), label=_('License'),
help_text=_('The license controls which features and functionality are ' help_text=_('The license controls which features and functionality are '
'enabled. Use /api/v2/config/ to update or change ' 'enabled. Use /api/v2/config/ to update or change '
@@ -124,7 +111,7 @@ register(
encrypted=False, encrypted=False,
read_only=False, read_only=False,
label=_('Red Hat customer username'), 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=_('System'),
category_slug='system', category_slug='system',
) )
@@ -137,7 +124,33 @@ register(
encrypted=True, encrypted=True,
read_only=False, read_only=False,
label=_('Red Hat customer password'), 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=_('System'),
category_slug='system', category_slug='system',
) )

View File

@@ -1,10 +1,7 @@
import cProfile
import json import json
import logging import logging
import os import os
import pstats
import signal import signal
import tempfile
import time import time
import traceback import traceback
@@ -23,6 +20,7 @@ from awx.main.models import (JobEvent, AdHocCommandEvent, ProjectUpdateEvent,
Job) Job)
from awx.main.tasks import handle_success_and_failure_notifications from awx.main.tasks import handle_success_and_failure_notifications
from awx.main.models.events import emit_event_detail from awx.main.models.events import emit_event_detail
from awx.main.utils.profiling import AWXProfiler
from .base import BaseWorker from .base import BaseWorker
@@ -48,6 +46,7 @@ class CallbackBrokerWorker(BaseWorker):
self.buff = {} self.buff = {}
self.pid = os.getpid() self.pid = os.getpid()
self.redis = redis.Redis.from_url(settings.BROKER_URL) self.redis = redis.Redis.from_url(settings.BROKER_URL)
self.prof = AWXProfiler("CallbackBrokerWorker")
for key in self.redis.keys('awx_callback_receiver_statistics_*'): for key in self.redis.keys('awx_callback_receiver_statistics_*'):
self.redis.delete(key) self.redis.delete(key)
@@ -87,19 +86,12 @@ class CallbackBrokerWorker(BaseWorker):
) )
def toggle_profiling(self, *args): def toggle_profiling(self, *args):
if self.prof: if not self.prof.is_started():
self.prof.disable() self.prof.start()
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()
logger.error('profiling is enabled') logger.error('profiling is enabled')
else:
filepath = self.prof.stop()
logger.error(f'profiling is disabled, wrote {filepath}')
def work_loop(self, *args, **kw): def work_loop(self, *args, **kw):
if settings.AWX_CALLBACK_PROFILE: if settings.AWX_CALLBACK_PROFILE:

View File

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

View File

@@ -8,5 +8,7 @@ class Command(MakeMigrations):
def execute(self, *args, **options): def execute(self, *args, **options):
settings = connections['default'].settings_dict.copy() settings = connections['default'].settings_dict.copy()
settings['ENGINE'] = 'sqlite3' settings['ENGINE'] = 'sqlite3'
if 'application_name' in settings['OPTIONS']:
del settings['OPTIONS']['application_name']
connections['default'] = DatabaseWrapper(settings) connections['default'] = DatabaseWrapper(settings)
return MakeMigrations().execute(*args, **options) 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): def check_license(self):
license_info = get_licenser().validate() license_info = get_licenser().validate()
local_license_type = license_info.get('license_type', 'UNLICENSED') 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) logger.error(LICENSE_NON_EXISTANT_MESSAGE)
raise CommandError('No license found!') raise CommandError('No license found!')
elif local_license_type == 'open': elif local_license_type == 'open':

View File

@@ -19,7 +19,9 @@ class Command(BaseCommand):
profile_sql.delay( profile_sql.delay(
threshold=options['threshold'], minutes=options['minutes'] threshold=options['threshold'], minutes=options['minutes']
) )
print(f"Logging initiated with a threshold of {options['threshold']} second(s) and a duration of" if options['threshold'] > 0:
f" {options['minutes']} minute(s), any queries that meet criteria can" print(f"SQL profiling initiated with a threshold of {options['threshold']} second(s) and a"
f" be found in /var/log/tower/profile/." 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. # Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
import uuid
import logging import logging
import threading import threading
import time import time
import cProfile
import pstats
import os
import urllib.parse import urllib.parse
from django.conf import settings 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.main.utils.named_url_graph import generate_graph, GraphNode
from awx.conf import fields, register from awx.conf import fields, register
from awx.main.utils.profiling import AWXProfiler
logger = logging.getLogger('awx.main.middleware') logger = logging.getLogger('awx.main.middleware')
@@ -32,11 +29,14 @@ class TimingMiddleware(threading.local, MiddlewareMixin):
dest = '/var/log/tower/profile' dest = '/var/log/tower/profile'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.prof = AWXProfiler("TimingMiddleware")
def process_request(self, request): def process_request(self, request):
self.start_time = time.time() self.start_time = time.time()
if settings.AWX_REQUEST_PROFILE: if settings.AWX_REQUEST_PROFILE:
self.prof = cProfile.Profile() self.prof.start()
self.prof.enable()
def process_response(self, request, response): def process_response(self, request, response):
if not hasattr(self, 'start_time'): # some tools may not invoke process_request 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 total_time = time.time() - self.start_time
response['X-API-Total-Time'] = '%0.3fs' % total_time response['X-API-Total-Time'] = '%0.3fs' % total_time
if settings.AWX_REQUEST_PROFILE: if settings.AWX_REQUEST_PROFILE:
self.prof.disable() response['X-API-Profile-File'] = self.prof.stop()
cprofile_file = self.save_profile_file(request)
response['cprofile_file'] = cprofile_file
perf_logger.info('api response times', extra=dict(python_objects=dict(request=request, response=response))) perf_logger.info('api response times', extra=dict(python_objects=dict(request=request, response=response)))
return 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): class SessionTimeoutMiddleware(MiddlewareMixin):
""" """

View File

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

View File

@@ -5,6 +5,7 @@ from uuid import uuid4
from django.utils.encoding import smart_text from django.utils.encoding import smart_text
from django.utils.timezone import now 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 from awx.main.utils.common import parse_yaml_or_json
logger = logging.getLogger('awx.main.migrations') 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): def delete_cloudforms_inv_source(apps, schema_editor):
"""Only applies for cloudforms in practice, but written generally. set_current_apps(apps)
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'
InventorySource = apps.get_model('main', 'InventorySource') InventorySource = apps.get_model('main', 'InventorySource')
ContentType = apps.get_model('contenttypes', 'ContentType') InventoryUpdate = apps.get_model('main', 'InventoryUpdate')
Project = apps.get_model('main', 'Project') CredentialType = apps.get_model('main', 'CredentialType')
if not InventorySource.objects.filter(source=source).exists(): InventoryUpdate.objects.filter(inventory_source__source='cloudforms').delete()
logger.debug('No sources of type {} to migrate'.format(source)) InventorySource.objects.filter(source='cloudforms').delete()
return ct = CredentialType.objects.filter(namespace='cloudforms').first()
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
if ct: 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( ManagedCredentialType(
namespace='gce', namespace='gce',
kind='cloud', kind='cloud',

View File

@@ -261,18 +261,20 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin):
app_label = 'main' 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 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 \ if i.remaining_capacity >= task.task_impact and \
(instance_most_capacity is None or (instance_most_capacity is None or
i.remaining_capacity > instance_most_capacity.remaining_capacity): i.remaining_capacity > instance_most_capacity.remaining_capacity):
instance_most_capacity = i instance_most_capacity = i
return instance_most_capacity return instance_most_capacity
def find_largest_idle_instance(self): @staticmethod
def find_largest_idle_instance(instances):
largest_instance = None 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 i.jobs_running == 0:
if largest_instance is None: if largest_instance is None:
largest_instance = i largest_instance = i

View File

@@ -798,6 +798,10 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
if self.project: if self.project:
for name in ('awx', 'tower'): for name in ('awx', 'tower'):
r['{}_project_revision'.format(name)] = self.project.scm_revision 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: if self.job_template:
for name in ('awx', 'tower'): for name in ('awx', 'tower'):
r['{}_job_template_id'.format(name)] = self.job_template.pk 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 status changed, update the parent instance.
if self.status != status_before: 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. # Done.
return result return result

View File

@@ -57,6 +57,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
def send_messages(self, messages): def send_messages(self, messages):
sent_messages = 0 sent_messages = 0
self.headers['Content-Type'] = 'application/json'
if 'User-Agent' not in self.headers: if 'User-Agent' not in self.headers:
self.headers['User-Agent'] = "Tower {}".format(get_awx_version()) self.headers['User-Agent'] = "Tower {}".format(get_awx_version())
if self.http_method.lower() not in ['put','post']: if self.http_method.lower() not in ['put','post']:
@@ -68,7 +69,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase):
auth = (self.username, self.password) auth = (self.username, self.password)
r = chosen_method("{}".format(m.recipients()[0]), r = chosen_method("{}".format(m.recipients()[0]),
auth=auth, auth=auth,
json=m.body, data=json.dumps(m.body, ensure_ascii=False).encode('utf-8'),
headers=self.headers, headers=self.headers,
verify=(not self.disable_ssl_verification)) verify=(not self.disable_ssl_verification))
if r.status_code >= 400: 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') 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): class PodManager(object):
def __init__(self, task=None): def __init__(self, task=None):
@@ -128,11 +146,13 @@ class PodManager(object):
pod_spec = {**default_pod_spec, **pod_spec_override} pod_spec = {**default_pod_spec, **pod_spec_override}
if self.task: if self.task:
pod_spec['metadata']['name'] = self.pod_name pod_spec['metadata'] = deepmerge(
pod_spec['metadata']['labels'] = { pod_spec.get('metadata', {}),
'ansible-awx': settings.INSTALL_UUID, dict(name=self.pod_name,
'ansible-awx-job-id': str(self.task.id) labels={
} 'ansible-awx': settings.INSTALL_UUID,
'ansible-awx-job-id': str(self.task.id)
}))
pod_spec['spec']['containers'][0]['name'] = self.pod_name pod_spec['spec']['containers'][0]['name'] = self.pod_name
return pod_spec return pod_spec

View File

@@ -7,12 +7,14 @@ import logging
import uuid import uuid
import json import json
import random import random
from types import SimpleNamespace
# Django # Django
from django.db import transaction, connection from django.db import transaction, connection
from django.utils.translation import ugettext_lazy as _, gettext_noop from django.utils.translation import ugettext_lazy as _, gettext_noop
from django.utils.timezone import now as tz_now from django.utils.timezone import now as tz_now
from django.conf import settings from django.conf import settings
from django.db.models import Q
# AWX # AWX
from awx.main.dispatch.reaper import reap_job from awx.main.dispatch.reaper import reap_job
@@ -45,6 +47,15 @@ logger = logging.getLogger('awx.main.scheduler')
class TaskManager(): class TaskManager():
def __init__(self): 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() self.graph = dict()
# start task limit indicates how many pending jobs can be started on this # 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 # .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 # 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. # will no longer be started and will be started on the next task manager cycle.
self.start_task_limit = settings.START_TASK_LIMIT 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'): for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name), self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name),
capacity_total=rampart_group.capacity, 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): 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 # 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(): for group in InstanceGroup.objects.all():
if group.is_containerized or group.controller_id: if group.is_containerized or group.controller_id:
continue 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: if match:
break break
task.instance_group = rampart_group task.instance_group = rampart_group
@@ -466,7 +497,6 @@ class TaskManager():
continue continue
preferred_instance_groups = task.preferred_instance_groups preferred_instance_groups = task.preferred_instance_groups
found_acceptable_queue = False found_acceptable_queue = False
idle_instance_that_fits = None
if isinstance(task, WorkflowJob): if isinstance(task, WorkflowJob):
if task.unified_job_template_id in running_workflow_templates: if task.unified_job_template_id in running_workflow_templates:
if not task.allow_simultaneous: if not task.allow_simultaneous:
@@ -483,24 +513,24 @@ class TaskManager():
found_acceptable_queue = True found_acceptable_queue = True
break 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) remaining_capacity = self.get_remaining_capacity(rampart_group.name)
if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0: if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0:
logger.debug("Skipping group {}, remaining_capacity {} <= 0".format( logger.debug("Skipping group {}, remaining_capacity {} <= 0".format(
rampart_group.name, remaining_capacity)) rampart_group.name, remaining_capacity))
continue continue
execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task) execution_instance = InstanceGroup.fit_task_to_most_remaining_capacity_instance(task, self.graph[rampart_group.name]['instances']) or \
if execution_instance: InstanceGroup.find_largest_idle_instance(self.graph[rampart_group.name]['instances'])
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:
elif not execution_instance and idle_instance_that_fits:
if not 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( logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) 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.graph[rampart_group.name]['graph'].add_job(task)
self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance) self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance)
found_acceptable_queue = True found_acceptable_queue = True
@@ -572,6 +602,9 @@ class TaskManager():
def _schedule(self): def _schedule(self):
finished_wfjs = [] finished_wfjs = []
all_sorted_tasks = self.get_tasks() all_sorted_tasks = self.get_tasks()
self.after_lock_init()
if len(all_sorted_tasks) > 0: if len(all_sorted_tasks) > 0:
# TODO: Deal with # TODO: Deal with
# latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks) # 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') @task(queue='tower_broadcast_all')
def profile_sql(threshold=1, minutes=1): def profile_sql(threshold=1, minutes=1):
if threshold == 0: if threshold <= 0:
cache.delete('awx-profile-sql-threshold') cache.delete('awx-profile-sql-threshold')
logger.error('SQL PROFILING DISABLED') logger.error('SQL PROFILING DISABLED')
else: else:
@@ -2160,7 +2160,7 @@ class RunProjectUpdate(BaseTask):
'local_path': os.path.basename(project_update.project.local_path), 'local_path': os.path.basename(project_update.project.local_path),
'project_path': project_update.get_project_path(check_if_exists=False), # deprecated 'project_path': project_update.get_project_path(check_if_exists=False), # deprecated
'insights_url': settings.INSIGHTS_URL_BASE, '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(), 'awx_version': get_awx_version(),
'scm_url': scm_url, 'scm_url': scm_url,
'scm_branch': scm_branch, 'scm_branch': scm_branch,

View File

@@ -675,33 +675,6 @@ def test_net_create_ok(post, organization, admin):
assert cred.inputs['authorize'] is True 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 # GCE Credentials
# #

View File

@@ -99,3 +99,12 @@ def test_changing_overwrite_behavior_okay_if_not_used(post, patch, organization,
expect=200 expect=200
) )
assert Project.objects.get(pk=r1.data['id']).allow_override is False 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) list_serializer.child.to_representation(project)
assert 'capability_map' not in list_serializer.child.context 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 @pytest.mark.django_db
def test_prefetch_group_capabilities(group, rando): 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', 1, 60),
('MINUTELY', 15, 15 * 60), ('MINUTELY', 15, 15 * 60),
('HOURLY', 1, 3600), ('HOURLY', 1, 3600),
('HOURLY', 4, 3600 * 4), ('HOURLY', 2, 3600 * 2),
)) ))
def test_really_old_dtstart(post, admin_user, freq, delta, total_seconds): def test_really_old_dtstart(post, admin_user, freq, delta, total_seconds):
url = reverse('api:schedule_rrule') 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', url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': approval_node.pk, 'version': 'v2'}) kwargs={'pk': approval_node.pk, 'version': 'v2'})
post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0}, 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) approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk)
assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate) 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) assert {'name': ['This field may not be blank.']} == json.loads(r.content)
@pytest.mark.parametrize("is_admin, is_org_admin, status", [ @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, 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): 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', url = reverse('api:workflow_job_template_node_create_approval',
@@ -165,7 +165,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval', url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': node.pk, 'version': 'v2'}) kwargs={'pk': node.pk, 'version': 'v2'})
post(url, {'name': 'Approve Test', 'description': '', 'timeout': 0}, 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}), post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
user=admin_user, expect=201) user=admin_user, expect=201)
wf_job = WorkflowJob.objects.first() wf_job = WorkflowJob.objects.first()
@@ -195,7 +195,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval', url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': node.pk, 'version': 'v2'}) kwargs={'pk': node.pk, 'version': 'v2'})
post(url, {'name': 'Deny Test', 'description': '', 'timeout': 0}, 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}), post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
user=admin_user, expect=201) user=admin_user, expect=201)
wf_job = WorkflowJob.objects.first() 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', 'aws',
'azure_kv', 'azure_kv',
'azure_rm', 'azure_rm',
'cloudforms',
'conjur', 'conjur',
'galaxy_api_token', 'galaxy_api_token',
'gce', 'gce',

View File

@@ -5,7 +5,7 @@ from awx.main.migrations import _inventory_source as invsrc
from django.apps import apps 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', [ @pytest.mark.parametrize('vars,id_var,result', [
@@ -42,16 +42,40 @@ def test_apply_new_instance_id(inventory_source):
@pytest.mark.django_db @pytest.mark.django_db
def test_replacement_scm_sources(inventory): def test_cloudforms_inventory_removal(inventory):
inv_source = InventorySource.objects.create( ManagedCredentialType(
name='test', name='Red Hat CloudForms',
inventory=inventory, namespace='cloudforms',
organization=inventory.organization, kind='cloud',
source='ec2' managed_by_tower=True,
inputs={},
) )
invsrc.create_scm_script_substitute(apps, 'ec2') CredentialType.defaults['cloudforms']().save()
inv_source.refresh_from_db() cloudforms = CredentialType.objects.get(namespace='cloudforms')
assert inv_source.source == 'scm' Credential.objects.create(
assert inv_source.source_project name='test',
project = inv_source.source_project credential_type=cloudforms,
assert 'Replacement project for' in project.name )
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 # Check variations of '-' and '_' in filenames due to python
for fname in [name, name.replace('-','_')]: for fname in [name, name.replace('-','_')]:
if entry.startswith(fname) and entry.endswith('.tar.gz'): if entry.startswith(fname) and entry.endswith('.tar.gz'):
entry = entry[:-7] v = entry.split(name + '-')[1].split('.tar.gz')[0]
(n, v) = entry.rsplit('-',1)
return v return v
return None 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!"), (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): def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason):
with mock.patch.object(InstanceGroup, ig = InstanceGroup(id=10)
'instances',
Mock(spec_set=['filter'],
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
order_by=lambda x: instances))):
ig = InstanceGroup(id=10)
if instance_fit_index is None: instance_picked = ig.fit_task_to_most_remaining_capacity_instance(task, instances)
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
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', [ @pytest.mark.parametrize('instances,instance_fit_index,reason', [
(Is([(0, 100)]), 0, "One idle instance, pick it"), (Is([(0, 100)]), 0, "One idle instance, pick it"),
@@ -70,16 +65,12 @@ class TestInstanceGroup(object):
def filter_offline_instances(*args): def filter_offline_instances(*args):
return filter(lambda i: i.capacity > 0, instances) return filter(lambda i: i.capacity > 0, instances)
with mock.patch.object(InstanceGroup, ig = InstanceGroup(id=10)
'instances', instances_online_only = filter_offline_instances(instances)
Mock(spec_set=['filter'],
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
order_by=filter_offline_instances))):
ig = InstanceGroup(id=10)
if instance_fit_index is None: if instance_fit_index is None:
assert ig.find_largest_idle_instance() is None, reason assert ig.find_largest_idle_instance(instances_online_only) is None, reason
else: else:
assert ig.find_largest_idle_instance() == \ assert ig.find_largest_idle_instance(instances_online_only) == \
instances[instance_fit_index], reason 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 import encrypt_field, encrypt_value
from awx.main.utils.safe_yaml import SafeLoader from awx.main.utils.safe_yaml import SafeLoader
from awx.main.utils.licensing import Licenser
class TestJobExecution(object): class TestJobExecution(object):
EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----' EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----'
@@ -1830,7 +1832,10 @@ class TestProjectUpdateGalaxyCredentials(TestJobExecution):
task = RunProjectUpdate() task = RunProjectUpdate()
env = task.build_env(project_update, private_data_dir) 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__['roles_enabled'] is False
assert task.__vars__['collections_enabled'] is False assert task.__vars__['collections_enabled'] is False
for k in env: for k in env:
@@ -1850,7 +1855,10 @@ class TestProjectUpdateGalaxyCredentials(TestJobExecution):
project_update.project.organization.galaxy_credentials.add(public_galaxy) project_update.project.organization.galaxy_credentials.add(public_galaxy)
task = RunProjectUpdate() task = RunProjectUpdate()
env = task.build_env(project_update, private_data_dir) 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__['roles_enabled'] is True
assert task.__vars__['collections_enabled'] is True assert task.__vars__['collections_enabled'] is True
assert sorted([ assert sorted([
@@ -1935,7 +1943,9 @@ class TestProjectUpdateCredentials(TestJobExecution):
assert settings.PROJECTS_ROOT in process_isolation['process_isolation_show_paths'] assert settings.PROJECTS_ROOT in process_isolation['process_isolation_show_paths']
task._write_extra_vars_file = mock.Mock() 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] call_args, _ = task._write_extra_vars_file.call_args_list[0]
_, extra_vars = call_args _, extra_vars = call_args

View File

@@ -55,8 +55,7 @@ __all__ = [
'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest',
'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError',
'get_custom_venv_choices', 'get_external_account', 'task_manager_bulk_reschedule', 'get_custom_venv_choices', 'get_external_account', 'task_manager_bulk_reschedule',
'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout', 'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout'
'StubLicense'
] ]
@@ -190,7 +189,7 @@ def get_awx_version():
def get_awx_http_client_headers(): def get_awx_http_client_headers():
license = get_license(show_key=False).get('license_type', 'UNLICENSED') license = get_license().get('license_type', 'UNLICENSED')
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': '{} {} ({})'.format( 'User-Agent': '{} {} ({})'.format(
@@ -202,34 +201,15 @@ def get_awx_http_client_headers():
return 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): def get_licenser(*args, **kwargs):
from awx.main.utils.licensing import Licenser, OpenLicense
try: try:
from tower_license import TowerLicense if os.path.exists('/var/lib/awx/.tower_version'):
return TowerLicense(*args, **kwargs) return Licenser(*args, **kwargs)
except ImportError: else:
return StubLicense(*args, **kwargs) 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, 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 gather_facts: false
connection: local connection: local
name: Install content with ansible-galaxy command if necessary name: Install content with ansible-galaxy command if necessary
vars:
yaml_exts:
- {ext: .yml}
- {ext: .yaml}
tasks: tasks:
- block: - block:
- name: detect requirements.yml - name: detect roles/requirements.(yml/yaml)
stat: stat:
path: '{{project_path|quote}}/roles/requirements.yml' path: "{{project_path|quote}}/roles/requirements{{ item.ext }}"
with_items: "{{ yaml_exts }}"
register: doesRequirementsExist register: doesRequirementsExist
- name: fetch galaxy roles from requirements.yml - name: fetch galaxy roles from requirements.(yml/yaml)
command: > 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 --roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args: args:
chdir: "{{project_path|quote}}" chdir: "{{project_path|quote}}"
register: galaxy_result register: galaxy_result
when: doesRequirementsExist.stat.exists with_items: "{{ doesRequirementsExist.results }}"
when: item.stat.exists
changed_when: "'was installed successfully' in galaxy_result.stdout" changed_when: "'was installed successfully' in galaxy_result.stdout"
environment: environment:
ANSIBLE_FORCE_COLOR: false ANSIBLE_FORCE_COLOR: false
@@ -186,20 +192,22 @@
- install_roles - install_roles
- block: - block:
- name: detect collections/requirements.yml - name: detect collections/requirements.(yml/yaml)
stat: stat:
path: '{{project_path|quote}}/collections/requirements.yml' path: "{{project_path|quote}}/collections/requirements{{ item.ext }}"
with_items: "{{ yaml_exts }}"
register: doesCollectionRequirementsExist register: doesCollectionRequirementsExist
- name: fetch galaxy collections from collections/requirements.yml - name: fetch galaxy collections from collections/requirements.(yml/yaml)
command: > 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 --collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }} {{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args: args:
chdir: "{{project_path|quote}}" chdir: "{{project_path|quote}}"
register: galaxy_collection_result register: galaxy_collection_result
when: doesCollectionRequirementsExist.stat.exists with_items: "{{ doesCollectionRequirementsExist.results }}"
when: item.stat.exists
changed_when: "'Installing ' in galaxy_collection_result.stdout" changed_when: "'Installing ' in galaxy_collection_result.stdout"
environment: environment:
ANSIBLE_FORCE_COLOR: false ANSIBLE_FORCE_COLOR: false

View File

@@ -184,3 +184,6 @@ else:
pass pass
AWX_CALLBACK_PROFILE = True 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: else:
raise raise
# The below runs AFTER all of the custom settings are imported.
CELERYBEAT_SCHEDULE.update({ # noqa CELERYBEAT_SCHEDULE.update({ # noqa
'isolated_heartbeat': { 'isolated_heartbeat': {
@@ -110,3 +111,5 @@ CELERYBEAT_SCHEDULE.update({ # noqa
'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2}, # 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 isExternal = credentialType.get('kind') === 'external';
const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit'; const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit';
vm.isEditable = credential.get('summary_fields.user_capabilities.edit');
vm.mode = mode; vm.mode = mode;
vm.strings = strings; vm.strings = strings;
@@ -52,6 +53,7 @@ function AddEditCredentialsController (
vm.form = credential.createFormSchema({ omit }); vm.form = credential.createFormSchema({ omit });
vm.form.disabled = !isEditable; vm.form.disabled = !isEditable;
} }
vm.form.disabled = !vm.isEditable;
vm.form._organization._disabled = !isOrgEditableByUser; vm.form._organization._disabled = !isOrgEditableByUser;
// Only exists for permissions compatibility // Only exists for permissions compatibility

View File

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

View File

@@ -139,7 +139,7 @@ export default ['$state', 'ConfigData', '$scope', 'SourcesFormDefinition', 'Pars
else{ else{
$scope.credentialBasePath = (source === 'ec2') ? GetBasePath('credentials') + '?credential_type__namespace=aws' : GetBasePath('credentials') + (source === '' ? '' : '?credential_type__namespace=' + (source)); $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'; $scope.envParseType = 'yaml';
var varName; var varName;

View File

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

View File

@@ -174,9 +174,11 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType', parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"), dataTitle: i18n._("Source Variables"),
dataPlacement: 'right', 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 ") + awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"https://github.com/ansible-collections/community.aws/blob/main/scripts/inventory/ec2.ini\" target=\"_blank\">" + "<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("view ec2.ini in the community.aws repo.") + "</a></p>" + 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>" + "<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" + i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\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', parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"), dataTitle: i18n._("Source Variables"),
dataPlacement: 'right', 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 ") + awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"https://github.com/ansible-collections/vmware/blob/main/scripts/inventory/vmware_inventory.ini\" target=\"_blank\">" + "<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("view vmware_inventory.ini in the vmware community repo.") + "</a></p>" + 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>" + "<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" + i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\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', parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"), dataTitle: i18n._("Source Variables"),
dataPlacement: 'right', dataPlacement: 'right',
awPopOver: i18n._("Override variables found in openstack.yml and used by the inventory update script. For an example variable configuration") + awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
'<a href=\"https://github.com/openstack/ansible-collections-openstack/blob/master/scripts/inventory/openstack.yml\" target=\"_blank\">' + "<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" 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."), 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', dataContainer: 'body',
subForm: 'sourceSubForm' subForm: 'sourceSubForm'
}, },
@@ -256,9 +269,18 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType', parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"), dataTitle: i18n._("Source Variables"),
dataPlacement: 'right', dataPlacement: 'right',
awPopOver: i18n._("Override variables found in foreman.ini and used by the inventory update script. For an example variable configuration") + awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
'<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/foreman.ini\" target=\"_blank\">' + "<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" 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."), 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', dataContainer: 'body',
subForm: 'sourceSubForm' subForm: 'sourceSubForm'
}, },
@@ -273,9 +295,89 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){
parseTypeName: 'envParseType', parseTypeName: 'envParseType',
dataTitle: i18n._("Source Variables"), dataTitle: i18n._("Source Variables"),
dataPlacement: 'right', 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 ") + awPopOver: "<p>" + i18n._("Enter variables to configure the inventory source. For a detailed description of how to configure this plugin, see ") +
"<a href=\"https://github.com/ansible-collections/community.general/blob/main/scripts/inventory/azure_rm.ini\" target=\"_blank\">" + "<a href=\"http://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html#inventory-plugins\" target=\"blank\">" +
i18n._("view azure_rm.ini in the Ansible community.general github repo.") + "</a></p>" + 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>" + "<p>" + i18n._("Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "</p>" +
i18n._("JSON:") + "<br />\n" + i18n._("JSON:") + "<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\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; return config.license_info;
}, },
post: function(payload, eula){ post: function(payload, eula, attach){
var defaultUrl = GetBasePath('config'); var defaultUrl = GetBasePath('config') + (attach ? 'attach/' : '');
Rest.setUrl(defaultUrl); Rest.setUrl(defaultUrl);
var data = payload; var data = payload;
data.eula_accepted = eula;
if (!attach) {
data.eula_accepted = eula;
}
return Rest.post(JSON.stringify(data)) return Rest.post(JSON.stringify(data))
.then((response) =>{ .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; return response.data;
}) })
.catch(({data}) => { .catch(({data}) => {

View File

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

View File

@@ -5,10 +5,10 @@
<div class="List-titleText" translate>Details</div> <div class="List-titleText" translate>Details</div>
<div class="License-fields"> <div class="License-fields">
<div class="License-field"> <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"> <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-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>Invalid License</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> </div>
<div class="License-field"> <div class="License-field">
@@ -18,7 +18,7 @@
</div> </div>
</div> </div>
<div class="License-field"> <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"> <div class="License-field--content">
{{license.license_info.license_type}} {{license.license_info.license_type}}
</div> </div>
@@ -29,12 +29,6 @@
{{license.license_info.subscription_name}} {{license.license_info.subscription_name}}
</div> </div>
</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">
<div class="License-field--label" translate>Expires On</div> <div class="License-field--label" translate>Expires On</div>
<div class="License-field--content"> <div class="License-field--content">
@@ -64,53 +58,66 @@
{{license.license_info.current_instances}} {{license.license_info.current_instances}}
</div> </div>
</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--label" translate>Hosts Remaining</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.free_instances}} {{license.license_info.free_instances}}
</div> </div>
</div> </div>
</div> </div>
<div class="License-upgradeText" translate>If you are ready to upgrade, please contact us by clicking the button below</div> <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.ansible.com/renew" target="_blank"><button class="btn btn-primary" translate>Upgrade</button></a> <a href="https://www.redhat.com/contact" target="_blank"><button class="btn btn-primary" translate>Contact Us</button></a>
</div> </div>
</div> </div>
<div class="License-management" ng-class="{'License-management--missingLicense' : licenseMissing}"> <div class="License-management" ng-class="{'License-management--missingLicense' : licenseMissing}">
<div class="card at-Panel"> <div class="card at-Panel">
<div class="List-titleText">{{title}}</div> <div class="List-titleText">{{title}}</div>
<div class="License-body"> <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="input-group License-file--container">
<div class="License-file--left"> <div class="License-file--left">
<div class="d-block w-100"> <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"> <div class="AddPermissions-directions">
<span class="AddPermissions-directionNumber" ng-if="licenseMissing">
2
</span>
<span class="License-helperText"> <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> </span>
</div> </div>
<div class="License-subTitleText"> <div class="License-subTitleText">
<span class="Form-requiredAsterisk">*</span> <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>
<div class="License-helperText License-licenseStepHelp" translate>Upload a license file</div>
<div class="License-filePicker"> <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> <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"/> <input id="License-file" class="form-control" type="file" file-on-change="getKey"/>
</div> </div>
@@ -125,12 +132,12 @@
<div class="d-block w-100"> <div class="d-block w-100">
<div class="AddPermissions-directions"> <div class="AddPermissions-directions">
<span class="License-helperText"> <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> </span>
</div> </div>
<div class="License-rhCredField"> <div class="License-rhCredField">
<label class="License-label d-block" translate>USERNAME</label> <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>
<div class="License-rhCredField"> <div class="License-rhCredField">
<label class="License-label d-block" translate>PASSWORD</label> <label class="License-label d-block" translate>PASSWORD</label>
@@ -143,11 +150,11 @@
</span> </span>
</div> </div>
<div class="input-group" ng-if="!showPlaceholderPassword"> <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> </div>
<div class="License-getLicensesButton"> <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>
<div ng-if="selectedLicense.fullLicense"> <div ng-if="selectedLicense.fullLicense">
<div class="at-RowItem-label" translate> <div class="at-RowItem-label" translate>
@@ -158,6 +165,14 @@
</div> </div>
</div> </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"> <div class="License-subTitleText">
<span class="Form-requiredAsterisk">*</span> <span class="Form-requiredAsterisk">*</span>
<translate>End User License Agreement</translate> <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> <span ng-show="success == true" class="License-greenText License-submit--success pull-right" translate>Save successful!</span>
</div> </div>
<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> </div>
</div> </div>
@@ -223,12 +238,12 @@
<div class="Modal-body ng-binding"> <div class="Modal-body ng-binding">
<div class="License-modalBody"> <div class="License-modalBody">
<form> <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"> <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>
<div class="License-modalRowDetails"> <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-modalRowDetailsRow">
<div class="License-trialTag" ng-if="license.trial" translate> <div class="License-trialTag" ng-if="license.trial" translate>
Trial Trial
@@ -260,7 +275,7 @@
<button <button
ng-click="confirmLicenseSelection()" ng-click="confirmLicenseSelection()"
class="btn Modal-footerButton btn-success" class="btn Modal-footerButton btn-success"
ng-disabled="!selectedLicense.modalKey" ng-disabled="!selectedLicense.modalPoolId"
translate translate
> >
SELECT SELECT

View File

@@ -15,7 +15,7 @@ export default {
controller: 'licenseController', controller: 'licenseController',
data: {}, data: {},
ncyBreadcrumb: { ncyBreadcrumb: {
label: N_('LICENSE') label: N_('SUBSCRIPTION')
}, },
onEnter: ['$state', 'ConfigService', (state, configService) => { onEnter: ['$state', 'ConfigService', (state, configService) => {
return configService.getConfig() 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/`); Rest.setUrl(`${GetBasePath('settings')}system/`);
return Rest.get() return Rest.get()
.then(({data}) => { .then(({data}) => {
const rhCreds = {}; const subscriptionCreds = {};
if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") { if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") {
rhCreds.REDHAT_USERNAME = data.REDHAT_USERNAME; subscriptionCreds.SUBSCRIPTIONS_USERNAME = data.SUBSCRIPTIONS_USERNAME;
} }
if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") {
rhCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD; subscriptionCreds.SUBSCRIPTIONS_PASSWORD = data.SUBSCRIPTIONS_PASSWORD;
} }
return rhCreds; return subscriptionCreds;
}).catch(() => { }).catch(() => {
return {}; 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.visitor.id = 0;
options.account.id = "tower.ansible.com"; options.account.id = "tower.ansible.com";
return options;
}, },
setRole: function(options) { setRole: function(options) {

View File

@@ -62,7 +62,7 @@ export default
getSubscriptions: function(username, password) { getSubscriptions: function(username, password) {
Rest.setUrl(`${GetBasePath('config')}subscriptions`); 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 "" msgstr ""
#: client/src/license/license.partial.html:128 #: 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 "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:374 #: 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." msgstr "Indique la URL, el nombre cifrado o id del inventario remoto de Tower para importarlos."
#: client/src/license/license.partial.html:128 #: 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 "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." 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:374
#: client/src/templates/job_templates/job-template.form.js:382 #: 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." 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 #: 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 "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." 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:374
#: client/src/templates/job_templates/job-template.form.js:382 #: 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 "カスタムインベントリースクリプトに渡す環境変数を指定します。" msgstr "カスタムインベントリースクリプトに渡す環境変数を指定します。"
#: client/src/license/license.partial.html:128 #: 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 "Red Hat の顧客認証情報を指定して、利用可能なライセンス一覧から選択してください。使用した認証情報は、今後、ライセンスの更新や延長情報を取得する時に利用できるように保存されます。設定 > システムでこの情報は更新または削除できます。" msgstr "Red Hat の顧客認証情報を指定して、利用可能なライセンス一覧から選択してください。使用した認証情報は、今後、ライセンスの更新や延長情報を取得する時に利用できるように保存されます。設定 &gt; システムでこの情報は更新または削除できます。"
#: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382 #: 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." 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 #: 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 "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." 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:374
#: client/src/templates/job_templates/job-template.form.js:382 #: 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。" msgstr "提供要导入的远程 Tower 清单的命名 URL 编码名称或 ID。"
#: client/src/license/license.partial.html:128 #: 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 "提供您的红帽客户凭证,您可以从可用许可证列表中进行选择。您使用的凭证将存储以供将来用于检索续订或扩展许可证。您可以在“设置”>“系统”中更新或删除它们。" msgstr "提供您的红帽客户凭证,您可以从可用许可证列表中进行选择。您使用的凭证将存储以供将来用于检索续订或扩展许可证。您可以在“设置”&gt;“系统”中更新或删除它们。"
#: client/src/templates/job_templates/job-template.form.js:374 #: client/src/templates/job_templates/job-template.form.js:374
#: client/src/templates/job_templates/job-template.form.js:382 #: client/src/templates/job_templates/job-template.form.js:382

View File

@@ -7,7 +7,7 @@ describe('Controller: LicenseController', () => {
ConfigService, ConfigService,
ProcessErrors, ProcessErrors,
config, config,
rhCreds; subscriptionCreds;
beforeEach(angular.mock.module('awApp')); beforeEach(angular.mock.module('awApp'));
beforeEach(angular.mock.module('license', ($provide) => { beforeEach(angular.mock.module('license', ($provide) => {
@@ -23,7 +23,7 @@ describe('Controller: LicenseController', () => {
version: '3.1.0-devel' version: '3.1.0-devel'
}; };
rhCreds = { subscriptionCreds = {
password: '$encrypted$', password: '$encrypted$',
username: 'foo', username: 'foo',
} }
@@ -33,21 +33,21 @@ describe('Controller: LicenseController', () => {
$provide.value('ConfigService', ConfigService); $provide.value('ConfigService', ConfigService);
$provide.value('ProcessErrors', ProcessErrors); $provide.value('ProcessErrors', ProcessErrors);
$provide.value('config', config); $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(); scope = $rootScope.$new();
ConfigService = _ConfigService_; ConfigService = _ConfigService_;
ProcessErrors = _ProcessErrors_; ProcessErrors = _ProcessErrors_;
config = _config_; config = _config_;
rhCreds = _rhCreds_; subscriptionCreds = _subscriptionCreds_;
LicenseController = $controller('licenseController', { LicenseController = $controller('licenseController', {
$scope: scope, $scope: scope,
ConfigService: ConfigService, ConfigService: ConfigService,
ProcessErrors: ProcessErrors, ProcessErrors: ProcessErrors,
config: config, 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", "jest-websocket-mock": "^2.0.2",
"mock-socket": "^9.0.3", "mock-socket": "^9.0.3",
"prettier": "^1.18.2", "prettier": "^1.18.2",
"react-scripts": "^3.4.3" "react-scripts": "^3.4.4"
}, },
"scripts": { "scripts": {
"start": "PORT=3001 HTTPS=true DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start", "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 CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes'; import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials'; import Credentials from './models/Credentials';
import Dashboard from './models/Dashboard';
import Groups from './models/Groups'; import Groups from './models/Groups';
import Hosts from './models/Hosts'; import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups'; import InstanceGroups from './models/InstanceGroups';
@@ -42,6 +43,7 @@ const ConfigAPI = new Config();
const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes(); const CredentialTypesAPI = new CredentialTypes();
const CredentialsAPI = new Credentials(); const CredentialsAPI = new Credentials();
const DashboardAPI = new Dashboard();
const GroupsAPI = new Groups(); const GroupsAPI = new Groups();
const HostsAPI = new Hosts(); const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups(); const InstanceGroupsAPI = new InstanceGroups();
@@ -81,6 +83,7 @@ export {
CredentialInputSourcesAPI, CredentialInputSourcesAPI,
CredentialTypesAPI, CredentialTypesAPI,
CredentialsAPI, CredentialsAPI,
DashboardAPI,
GroupsAPI, GroupsAPI,
HostsAPI, HostsAPI,
InstanceGroupsAPI, InstanceGroupsAPI,

View File

@@ -20,10 +20,38 @@ class Credentials extends Base {
return this.http.options(`${this.baseUrl}${id}/access_list/`); return this.http.options(`${this.baseUrl}${id}/access_list/`);
} }
readInputSources(id, params) { readInputSources(id) {
return this.http.get(`${this.baseUrl}${id}/input_sources/`, { const maxRequests = 5;
params, 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) { 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) { 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/`); return this.http.options(`${this.baseUrl}${id}/teams/`);
} }
readGalaxyCredentials(id, params) {
return this.http.get(`${this.baseUrl}${id}/galaxy_credentials/`, {
params,
});
}
createUser(id, data) { createUser(id, data) {
return this.http.post(`${this.baseUrl}${id}/users/`, data); return this.http.post(`${this.baseUrl}${id}/users/`, data);
} }
@@ -48,6 +54,19 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
{ id: notificationId, disassociate: true } { 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; export default Organizations;

View File

@@ -77,21 +77,28 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) {
readNotificationTemplatesApprovals(id, params) { readNotificationTemplatesApprovals(id, params) {
return this.http.get( return this.http.get(
`${this.baseUrl}${id}/notification_templates_approvals/`, `${this.baseUrl}${id}/notification_templates_approvals/`,
{ params } {
params,
}
); );
} }
associateNotificationTemplatesApprovals(resourceId, notificationId) { associateNotificationTemplatesApprovals(resourceId, notificationId) {
return this.http.post( return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_approvals/`, `${this.baseUrl}${resourceId}/notification_templates_approvals/`,
{ id: notificationId } {
id: notificationId,
}
); );
} }
disassociateNotificationTemplatesApprovals(resourceId, notificationId) { disassociateNotificationTemplatesApprovals(resourceId, notificationId) {
return this.http.post( return this.http.post(
`${this.baseUrl}${resourceId}/notification_templates_approvals/`, `${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 React, { useCallback, useEffect, useState, useContext } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, DropdownItem } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../util/useRequest'; import useRequest, { useDismissableError } from '../../util/useRequest';
import { InventoriesAPI } from '../../api'; import { InventoriesAPI, CredentialTypesAPI } from '../../api';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard'; import AdHocCommandsWizard from './AdHocCommandsWizard';
import { KebabifiedContext } from '../../contexts/Kebabified';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
onClose,
adHocItems,
itemId,
i18n,
moduleOptions,
credentialTypeId,
}) {
const history = useHistory(); const history = useHistory();
const { id } = useParams();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const verbosityOptions = [ const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
{ value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, { 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: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection 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 { const {
isloading: isLaunchLoading, isloading: isLaunchLoading,
error, error: launchError,
request: launchAdHocCommands, request: launchAdHocCommands,
} = useRequest( } = useRequest(
useCallback( useCallback(
async values => { async values => {
const { data } = await InventoriesAPI.launchAdHocCommands( const { data } = await InventoriesAPI.launchAdHocCommands(id, values);
itemId,
values
);
history.push(`/jobs/command/${data.id}/output`); 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 handleSubmit = async values => {
const { credential, ...remainingValues } = values; const { credential, ...remainingValues } = values;
@@ -64,7 +90,7 @@ function AdHocCommands({
return <ContentLoading />; return <ContentLoading />;
} }
if (error) { if (error && isWizardOpen) {
return ( return (
<AlertModal <AlertModal
isOpen={error} isOpen={error}
@@ -72,31 +98,63 @@ function AdHocCommands({
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => { onClose={() => {
dismissError(); dismissError();
setIsWizardOpen(false);
}} }}
> >
<> {launchError ? (
{i18n._(t`Failed to launch job.`)} <>
<ErrorDetail error={error} /> {i18n._(t`Failed to launch job.`)}
</> <ErrorDetail error={error} />
</>
) : (
<ContentError error={error} />
)}
</AlertModal> </AlertModal>
); );
} }
return ( return (
<AdHocCommandsWizard // render buttons for drop down and for toolbar
adHocItems={adHocItems} // if modal is open render the modal
moduleOptions={moduleOptions} <>
verbosityOptions={verbosityOptions} {isKebabified ? (
credentialTypeId={credentialTypeId} <DropdownItem
onCloseWizard={onClose} key="cancel-job"
onLaunch={handleSubmit} isDisabled={isAdHocDisabled || !hasListItems}
onDismissError={() => dismissError()} 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 = { AdHocCommands.propTypes = {
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired, hasListItems: PropTypes.bool.isRequired,
}; };
export default withI18n()(AdHocCommands); export default withI18n()(AdHocCommands);

View File

@@ -10,7 +10,12 @@ import AdHocCommands from './AdHocCommands';
jest.mock('../../api/models/CredentialTypes'); jest.mock('../../api/models/CredentialTypes');
jest.mock('../../api/models/Inventories'); jest.mock('../../api/models/Inventories');
jest.mock('../../api/models/Credentials'); jest.mock('../../api/models/Credentials');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
}),
}));
const credentials = [ const credentials = [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Cred 2', 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: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]; ];
const moduleOptions = [
['command', 'command'],
['shell', 'shell'],
];
const adHocItems = [ const adHocItems = [
{ {
name: 'Inventory 1 Org 0', name: 'Inventory 1 Org 0',
@@ -30,6 +32,26 @@ const adHocItems = [
]; ];
describe('<AdHocCommands />', () => { 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; let wrapper;
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
@@ -39,19 +61,45 @@ describe('<AdHocCommands />', () => {
test('mounts successfully', async () => { test('mounts successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands adHocItems={adHocItems} hasListItems />
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
); );
}); });
expect(wrapper.find('AdHocCommands').length).toBe(1); 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 () => { test('should submit properly', async () => {
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } }); InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
@@ -62,17 +110,13 @@ describe('<AdHocCommands />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands adHocItems={adHocItems} hasListItems />
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
); );
}); });
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
@@ -174,17 +218,13 @@ describe('<AdHocCommands />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands adHocItems={adHocItems} hasListItems />
css="margin-right: 20px"
onClose={() => {}}
credentialTypeId={1}
itemId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
); );
}); });
await act(async () =>
wrapper.find('button[aria-label="Run Command"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
@@ -237,4 +277,69 @@ describe('<AdHocCommands />', () => {
await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0); 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 = { FormikApp.propTypes = {
onLaunch: PropTypes.func.isRequired, onLaunch: PropTypes.func.isRequired,
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
onCloseWizard: PropTypes.func.isRequired, onCloseWizard: PropTypes.func.isRequired,
credentialTypeId: PropTypes.number.isRequired, credentialTypeId: PropTypes.number.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
import { useField } from 'formik'; import { useField } from 'formik';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import CodeMirrorInput from './CodeMirrorInput'; import CodeMirrorInput from './CodeMirrorInput';
import { FieldTooltip } from '../FormField'; import Popover from '../Popover';
function CodeMirrorField({ function CodeMirrorField({
id, id,
@@ -37,7 +37,7 @@ function CodeMirrorField({
isRequired={isRequired} isRequired={isRequired}
validated={isValid ? 'default' : 'error'} validated={isValid ? 'default' : 'error'}
label={label} label={label}
labelIcon={<FieldTooltip content={tooltip} />} labelIcon={<Popover content={tooltip} />}
> >
<CodeMirrorInput <CodeMirrorInput
id={id} 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 { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList'; import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle'; import MultiButtonToggle from '../MultiButtonToggle';
import DetailPopover from '../DetailPopover'; import Popover from '../Popover';
import { import {
yamlToJson, yamlToJson,
jsonToYaml, jsonToYaml,
@@ -69,7 +69,7 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
{label} {label}
</span> </span>
{helpText && ( {helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} /> <Popover header={label} content={helpText} id={dataCy} />
)} )}
</div> </div>
</SplitItem> </SplitItem>
@@ -122,9 +122,13 @@ VariablesDetail.propTypes = {
value: oneOfType([shape({}), arrayOf(string), string]).isRequired, value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired, label: node.isRequired,
rows: number, rows: number,
dataCy: string,
helpText: string,
}; };
VariablesDetail.defaultProps = { VariablesDetail.defaultProps = {
rows: null, rows: null,
dataCy: '',
helpText: '',
}; };
export default VariablesDetail; export default VariablesDetail;

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import DataListToolbar from './DataListToolbar'; import DataListToolbar from './DataListToolbar';
import AddDropDownButton from '../AddDropDownButton/AddDropDownButton';
describe('<DataListToolbar />', () => { describe('<DataListToolbar />', () => {
let toolbar; let toolbar;
@@ -313,4 +315,44 @@ describe('<DataListToolbar />', () => {
search.prop('columns').filter(col => col.key === 'advanced').length search.prop('columns').filter(col => col.key === 'advanced').length
).toBe(1); ).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 'styled-components/macro';
import React from 'react'; 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 { TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from './Detail'; import { DetailName, DetailValue } from './Detail';
import CodeMirrorInput from '../CodeMirrorInput'; 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 ( return (
<> <>
<DetailName <DetailName
component={TextListItemVariants.dt} component={TextListItemVariants.dt}
fullWidth fullWidth
css="grid-column: 1 / -1" css="grid-column: 1 / -1"
data-cy={labelCy}
> >
<div className="pf-c-form__label"> <div className="pf-c-form__label">
<span <span
@@ -20,12 +33,16 @@ function CodeDetail({ value, label, mode, rows, fullHeight }) {
> >
{label} {label}
</span> </span>
{helpText && (
<Popover header={label} content={helpText} id={dataCy} />
)}
</div> </div>
</DetailName> </DetailName>
<DetailValue <DetailValue
component={TextListItemVariants.dd} component={TextListItemVariants.dd}
fullWidth fullWidth
css="grid-column: 1 / -1; margin-top: -20px" css="grid-column: 1 / -1; margin-top: -20px"
data-cy={valueCy}
> >
<CodeMirrorInput <CodeMirrorInput
mode={mode} mode={mode}
@@ -42,11 +59,15 @@ function CodeDetail({ value, label, mode, rows, fullHeight }) {
CodeDetail.propTypes = { CodeDetail.propTypes = {
value: shape.isRequired, value: shape.isRequired,
label: node.isRequired, label: node.isRequired,
dataCy: string,
helpText: string,
rows: number, rows: number,
mode: oneOf(['json', 'yaml', 'jinja2']).isRequired, mode: oneOf(['json', 'yaml', 'jinja2']).isRequired,
}; };
CodeDetail.defaultProps = { CodeDetail.defaultProps = {
rows: null, rows: null,
helpText: '',
dataCy: '',
}; };
export default CodeDetail; export default CodeDetail;

View File

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