From 4445d096f575b6985b9bf32a5db7de59ae271b4c Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 22 Oct 2020 10:56:26 -0400 Subject: [PATCH] reimplement licensing to work with RHSM and entitlement uploads Co-authored-by: Christian Adams --- .gitignore | 1 + Makefile | 4 +- awx/api/generics.py | 5 +- awx/api/parsers.py | 25 ++ awx/api/urls/urls.py | 2 + awx/api/views/__init__.py | 1 + awx/api/views/root.py | 96 ++++- awx/conf/license.py | 12 +- awx/conf/migrations/0008_subscriptions.py | 20 + awx/conf/migrations/_subscriptions.py | 17 + awx/conf/models.py | 8 - awx/main/access.py | 4 +- awx/main/analytics/collectors.py | 4 +- awx/main/analytics/core.py | 2 +- awx/main/analytics/metrics.py | 2 +- awx/main/conf.py | 43 ++ awx/main/management/commands/check_license.py | 4 +- .../management/commands/inventory_import.py | 4 +- awx/main/tasks.py | 2 +- .../tests/functional/core/test_licenses.py | 17 +- awx/main/tests/functional/test_licenses.py | 3 +- awx/main/tests/unit/test_tasks.py | 16 +- awx/main/utils/common.py | 37 +- awx/main/utils/licensing.py | 382 ++++++++++++++++++ awx/main/validators.py | 6 + awx/settings/defaults.py | 4 + .../forms/settings-form.route.js | 10 +- .../src/license/checkLicense.factory.js | 18 +- .../client/src/license/license.controller.js | 82 ++-- .../client/src/license/license.partial.html | 51 ++- awx/ui/client/src/license/license.route.js | 20 +- .../src/shared/config/config.service.js | 2 +- awxkit/awxkit/api/pages/subscriptions.py | 3 + awxkit/awxkit/api/resources.py | 1 + docs/licenses/iniparse.txt | 30 ++ .../python-rhsm-python3-backport.tar.gz | Bin 0 -> 56535 bytes docs/licenses/python-rhsm.txt | 339 ++++++++++++++++ .../roles/image_build/templates/Dockerfile.j2 | 1 + .../kubernetes/templates/configmap.yml.j2 | 2 + requirements/requirements.txt | 3 +- requirements/requirements_git.txt | 1 + requirements/updater.sh | 7 +- 42 files changed, 1117 insertions(+), 174 deletions(-) create mode 100644 awx/conf/migrations/0008_subscriptions.py create mode 100644 awx/conf/migrations/_subscriptions.py create mode 100644 awx/main/utils/licensing.py create mode 100644 docs/licenses/iniparse.txt create mode 100644 docs/licenses/python-rhsm-python3-backport.tar.gz create mode 100644 docs/licenses/python-rhsm.txt diff --git a/.gitignore b/.gitignore index 7e0f07a83c..3b0fbc7d59 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,4 @@ use_dev_supervisor.txt .idea/* *.unison.tmp *.# +/tools/docker-compose/overrides/ diff --git a/Makefile b/Makefile index ed73f4a5b6..8d891c25b5 100644 --- a/Makefile +++ b/Makefile @@ -650,9 +650,11 @@ awx/projects: docker-compose-isolated: awx/projects CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-isolated-override.yml up +COMPOSE_UP_OPTS ?= "" + # Docker Compose Development environment docker-compose: docker-auth awx/projects - CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml up --no-recreate awx + CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml $(COMPOSE_UP_OPTS) up --no-recreate awx docker-compose-cluster: docker-auth awx/projects CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up diff --git a/awx/api/generics.py b/awx/api/generics.py index fce5bb9b49..0c6f2637f6 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -47,8 +47,6 @@ from awx.main.utils import ( get_object_or_400, decrypt_field, get_awx_version, - get_licenser, - StubLicense ) from awx.main.utils.db import get_all_field_names from awx.main.views import ApiErrorView @@ -225,7 +223,8 @@ class APIView(views.APIView): response = super(APIView, self).finalize_response(request, response, *args, **kwargs) time_started = getattr(self, 'time_started', None) response['X-API-Product-Version'] = get_awx_version() - response['X-API-Product-Name'] = 'AWX' if isinstance(get_licenser(), StubLicense) else 'Red Hat Ansible Tower' + response['X-API-Product-Name'] = 'AWX' if settings.LICENSE.get('license_type', 'UNLICENSED') in 'open' else 'Red Hat Ansible Tower' + response['X-API-Node'] = settings.CLUSTER_HOST_ID if time_started: time_elapsed = time.time() - self.time_started diff --git a/awx/api/parsers.py b/awx/api/parsers.py index ce18bce0af..8c9cfede94 100644 --- a/awx/api/parsers.py +++ b/awx/api/parsers.py @@ -34,3 +34,28 @@ class JSONParser(parsers.JSONParser): return obj except ValueError as exc: raise ParseError(_('JSON parse error - %s\nPossible cause: trailing comma.' % str(exc))) + + +class ConfigJSONParser(parsers.JSONParser): + """ + Entitlement Certificates have newlines in them which require json.loads to + not use strict parsing. + """ + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as JSON and returns the resulting data. + """ + parser_context = parser_context or {} + encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET) + + try: + data = smart_str(stream.read(), encoding=encoding) + if not data: + return {} + obj = json.loads(data, object_pairs_hook=OrderedDict, strict=False) + if not isinstance(obj, dict) and obj is not None: + raise ParseError(_('JSON parse error - not a JSON object')) + return obj + except ValueError as exc: + raise ParseError(_('JSON parse error - %s\nPossible cause: trailing comma.' % str(exc))) diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 2745f87095..636e68e4bd 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -15,6 +15,7 @@ from awx.api.views import ( ApiV2PingView, ApiV2ConfigView, ApiV2SubscriptionView, + ApiV2AttachView, AuthView, UserMeList, DashboardView, @@ -94,6 +95,7 @@ v2_urls = [ url(r'^ping/$', ApiV2PingView.as_view(), name='api_v2_ping_view'), url(r'^config/$', ApiV2ConfigView.as_view(), name='api_v2_config_view'), url(r'^config/subscriptions/$', ApiV2SubscriptionView.as_view(), name='api_v2_subscription_view'), + url(r'^config/attach/$', ApiV2AttachView.as_view(), name='api_v2_attach_view'), url(r'^auth/$', AuthView.as_view()), url(r'^me/$', UserMeList.as_view(), name='user_me_list'), url(r'^dashboard/$', DashboardView.as_view(), name='dashboard_view'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 3b5ffc9671..df138b8a6d 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -153,6 +153,7 @@ from awx.api.views.root import ( # noqa ApiV2PingView, ApiV2ConfigView, ApiV2SubscriptionView, + ApiV2AttachView, ) from awx.api.views.webhooks import ( # noqa WebhookKeyView, diff --git a/awx/api/views/root.py b/awx/api/views/root.py index aeda19cdeb..abcd0eed8a 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -1,9 +1,9 @@ # Copyright (c) 2018 Ansible, Inc. # All Rights Reserved. +import json import logging import operator -import json from collections import OrderedDict from django.conf import settings @@ -20,6 +20,7 @@ from rest_framework import status import requests from awx.api.generics import APIView +from awx.api.parsers import ConfigJSONParser from awx.conf.registry import settings_registry from awx.main.analytics import all_collectors from awx.main.ha import is_ha_environment @@ -30,7 +31,6 @@ from awx.main.utils import ( to_python_boolean, ) from awx.api.versioning import reverse, drf_reverse -from awx.conf.license import get_license from awx.main.constants import PRIVILEGE_ESCALATION_METHODS from awx.main.models import ( Project, @@ -178,7 +178,7 @@ class ApiV2PingView(APIView): class ApiV2SubscriptionView(APIView): permission_classes = (IsAuthenticated,) - name = _('Configuration') + name = _('Subscriptions') swagger_topic = 'System Configuration' def check_permissions(self, request): @@ -189,16 +189,16 @@ class ApiV2SubscriptionView(APIView): def post(self, request): from awx.main.utils.common import get_licenser data = request.data.copy() - if data.get('rh_password') == '$encrypted$': - data['rh_password'] = settings.REDHAT_PASSWORD + if data.get('subscriptions_password') == '$encrypted$': + data['subscriptions_password'] = settings.SUBSCRIPTIONS_PASSWORD try: - user, pw = data.get('rh_username'), data.get('rh_password') + user, pw = data.get('subscriptions_username'), data.get('subscriptions_password') with set_environ(**settings.AWX_TASK_ENV): validated = get_licenser().validate_rh(user, pw) if user: - settings.REDHAT_USERNAME = data['rh_username'] + settings.SUBSCRIPTIONS_USERNAME = data['subscriptions_username'] if pw: - settings.REDHAT_PASSWORD = data['rh_password'] + settings.SUBSCRIPTIONS_PASSWORD = data['subscriptions_password'] except Exception as exc: msg = _("Invalid License") if ( @@ -220,11 +220,63 @@ class ApiV2SubscriptionView(APIView): 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) + # org = data.get('org', None) # if we want allow to user to specify the org, we will need to pass this + 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 License") + 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 license submitted."), + extra=dict(actor=request.user.username)) + return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST) + for sub in validated: + if sub['pool_id'] == pool_id: + sub['valid_key'] = True + settings.LICENSE = sub + return Response(sub) + + return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST) + + class ApiV2ConfigView(APIView): permission_classes = (IsAuthenticated,) name = _('Configuration') swagger_topic = 'System Configuration' + parser_classes = (ConfigJSONParser,) def check_permissions(self, request): super(ApiV2ConfigView, self).check_permissions(request) @@ -234,15 +286,11 @@ class ApiV2ConfigView(APIView): def get(self, request, format=None): '''Return various sitewide configuration settings''' - if request.user.is_superuser or request.user.is_system_auditor: - license_data = get_license(show_key=True) - else: - license_data = get_license(show_key=False) + from awx.main.utils.common import get_licenser + license_data = get_licenser().validate(new_cert=False) + if not license_data.get('valid_key', False): license_data = {} - if license_data and 'features' in license_data and 'activity_streams' in license_data['features']: - # FIXME: Make the final setting value dependent on the feature? - license_data['features']['activity_streams'] &= settings.ACTIVITY_STREAM_ENABLED pendo_state = settings.PENDO_TRACKING_STATE if settings.PENDO_TRACKING_STATE in ('off', 'anonymous', 'detailed') else 'off' @@ -281,6 +329,7 @@ class ApiV2ConfigView(APIView): return Response(data) + def post(self, request): if not isinstance(request.data, dict): return Response({"error": _("Invalid license data")}, status=status.HTTP_400_BAD_REQUEST) @@ -300,18 +349,26 @@ class ApiV2ConfigView(APIView): logger.info(smart_text(u"Invalid JSON submitted for license."), extra=dict(actor=request.user.username)) return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST) + + # Save Entitlement Cert/Key + license_data = json.loads(data_actual) + if 'entitlement_cert' in license_data: + settings.ENTITLEMENT_CERT = license_data['entitlement_cert'] + try: + # Validate entitlement cert and get subscription metadata + # validate() will clear the entitlement cert if not valid from awx.main.utils.common import get_licenser - license_data = json.loads(data_actual) - license_data_validated = get_licenser(**license_data).validate() + license_data_validated = get_licenser().validate(new_cert=True) except Exception: logger.warning(smart_text(u"Invalid license submitted."), extra=dict(actor=request.user.username)) + # If License invalid, clear entitlment cert value + settings.ENTITLEMENT_CERT = '' return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) # If the license is valid, write it to the database. if license_data_validated['valid_key']: - settings.LICENSE = license_data if not settings_registry.is_setting_read_only('TOWER_URL_BASE'): settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host()) return Response(license_data_validated) @@ -321,9 +378,12 @@ class ApiV2ConfigView(APIView): return Response({"error": _("Invalid license")}, status=status.HTTP_400_BAD_REQUEST) def delete(self, request): + # Clear license and entitlement certificate try: settings.LICENSE = {} + settings.ENTITLEMENT_CERT = '' return Response(status=status.HTTP_204_NO_CONTENT) except Exception: # FIX: Log return Response({"error": _("Failed to remove license.")}, status=status.HTTP_400_BAD_REQUEST) + diff --git a/awx/conf/license.py b/awx/conf/license.py index 6ad1042f9a..b9d142a9f1 100644 --- a/awx/conf/license.py +++ b/awx/conf/license.py @@ -1,18 +1,14 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved. - __all__ = ['get_license'] def _get_validated_license_data(): - from awx.main.utils.common import get_licenser - return get_licenser().validate() + from awx.main.utils.licensing import Licenser + return Licenser().validate(new_cert=False) -def get_license(show_key=False): +def get_license(): """Return a dictionary representing the active license on this Tower instance.""" - license_data = _get_validated_license_data() - if not show_key: - license_data.pop('license_key', None) - return license_data + return _get_validated_license_data() diff --git a/awx/conf/migrations/0008_subscriptions.py b/awx/conf/migrations/0008_subscriptions.py new file mode 100644 index 0000000000..8b3dff6cf4 --- /dev/null +++ b/awx/conf/migrations/0008_subscriptions.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.11 on 2020-08-04 15:19 + +import logging + +from django.db import migrations + +from awx.conf.migrations import _subscriptions as subscriptions + +logger = logging.getLogger('awx.conf.migrations') + + +class Migration(migrations.Migration): + + dependencies = [ + ('conf', '0007_v380_rename_more_settings'), + ] + + operations = [ + migrations.RunPython(subscriptions.clear_old_license), + ] diff --git a/awx/conf/migrations/_subscriptions.py b/awx/conf/migrations/_subscriptions.py new file mode 100644 index 0000000000..b28c8dd195 --- /dev/null +++ b/awx/conf/migrations/_subscriptions.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +import logging +from django.conf import settings + +from awx.main.utils.licensing import Licenser + +logger = logging.getLogger('awx.conf.settings') + +__all__ = ['clear_old_license'] + + +def clear_old_license(apps, schema_editor): + # Setting = apps.get_model('conf', 'Organization') + # setting.objects.filter(key=LICENSE) + licenser = Licenser() + if licenser._check_product_cert(): + settings.LICENSE = licenser.UNLICENSED_DATA.copy() diff --git a/awx/conf/models.py b/awx/conf/models.py index 2859650f54..fe28fd89a8 100644 --- a/awx/conf/models.py +++ b/awx/conf/models.py @@ -78,14 +78,6 @@ class Setting(CreatedModifiedModel): def get_cache_id_key(self, key): return '{}_ID'.format(key) - def display_value(self): - if self.key == 'LICENSE' and 'license_key' in self.value: - # don't log the license key in activity stream - value = self.value.copy() - value['license_key'] = '********' - return value - return self.value - import awx.conf.signals # noqa diff --git a/awx/main/access.py b/awx/main/access.py index 0ecb025d92..536c8e4699 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -307,7 +307,7 @@ class BaseAccess(object): return True # User has access to both, permission check passed def check_license(self, add_host_name=None, feature=None, check_expiration=True, quiet=False): - validation_info = get_licenser().validate() + validation_info = get_licenser().validate(new_cert=False) if validation_info.get('license_type', 'UNLICENSED') == 'open': return @@ -345,7 +345,7 @@ class BaseAccess(object): report_violation(_("Host count exceeds available instances.")) def check_org_host_limit(self, data, add_host_name=None): - validation_info = get_licenser().validate() + validation_info = get_licenser().validate(new_cert=False) if validation_info.get('license_type', 'UNLICENSED') == 'open': return diff --git a/awx/main/analytics/collectors.py b/awx/main/analytics/collectors.py index e1dc468d51..bcbc7b0118 100644 --- a/awx/main/analytics/collectors.py +++ b/awx/main/analytics/collectors.py @@ -33,9 +33,9 @@ data _since_ the last report date - i.e., new data in the last 24 hours) ''' -@register('config', '1.1', description=_('General platform configuration.')) +@register('config', '1.2', description=_('General platform configuration.')) def config(since, **kwargs): - license_info = get_license(show_key=False) + license_info = get_license() install_type = 'traditional' if os.environ.get('container') == 'oci': install_type = 'openshift' diff --git a/awx/main/analytics/core.py b/awx/main/analytics/core.py index fe48fb30bf..2c77444929 100644 --- a/awx/main/analytics/core.py +++ b/awx/main/analytics/core.py @@ -24,7 +24,7 @@ logger = logging.getLogger('awx.main.analytics') def _valid_license(): try: - if get_license(show_key=False).get('license_type', 'UNLICENSED') == 'open': + if get_license().get('license_type', 'UNLICENSED') == 'open': return False access_registry[Job](None).check_license() except PermissionDenied: diff --git a/awx/main/analytics/metrics.py b/awx/main/analytics/metrics.py index 1dd85eb6a7..676ccdabf5 100644 --- a/awx/main/analytics/metrics.py +++ b/awx/main/analytics/metrics.py @@ -54,7 +54,7 @@ LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining def metrics(): - license_info = get_license(show_key=False) + license_info = get_license() SYSTEM_INFO.info({ 'install_uuid': settings.INSTALL_UUID, 'insights_analytics': str(settings.INSIGHTS_TRACKING_STATE), diff --git a/awx/main/conf.py b/awx/main/conf.py index 3b41c3a19b..95e15e9def 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -12,6 +12,8 @@ from rest_framework.fields import FloatField # Tower from awx.conf import fields, register, register_validate +from awx.main.validators import validate_entitlement_cert + logger = logging.getLogger('awx.main.conf') @@ -142,6 +144,32 @@ register( category_slug='system', ) +register( + 'SUBSCRIPTIONS_USERNAME', + field_class=fields.CharField, + default='', + allow_blank=True, + encrypted=False, + read_only=False, + label=_('Red Hat or Satellite username'), + help_text=_('This username is used to retrieve subscription and content information'), # noqa + category=_('System'), + category_slug='system', +) + +register( + 'SUBSCRIPTIONS_PASSWORD', + field_class=fields.CharField, + default='', + allow_blank=True, + encrypted=True, + read_only=False, + label=_('Red Hat or Satellite password'), + help_text=_('This password is used to retrieve subscription and content information'), # noqa + category=_('System'), + category_slug='system', +) + register( 'AUTOMATION_ANALYTICS_URL', field_class=fields.URLField, @@ -328,6 +356,21 @@ register( category_slug='jobs', ) +register( + 'ENTITLEMENT_CERT', + field_class=fields.CharField, + allow_blank=True, + default='', + required=False, + validators=[validate_entitlement_cert], # TODO: may need to use/modify `validate_certificate` validator + label=_('RHSM Entitlement Public Certificate and Private Key'), + help_text=_('Obtain a key pair via subscription-manager, or https://access.redhat.com. Refer to Ansible Tower docs for formatting key pair.'), + category=_('SYSTEM'), + category_slug='system', + encrypted=True, +) + + register( 'AWX_RESOURCE_PROFILING_ENABLED', field_class=fields.BooleanField, diff --git a/awx/main/management/commands/check_license.py b/awx/main/management/commands/check_license.py index 8c0798cc53..23d51c7e17 100644 --- a/awx/main/management/commands/check_license.py +++ b/awx/main/management/commands/check_license.py @@ -16,9 +16,7 @@ class Command(BaseCommand): def handle(self, *args, **options): super(Command, self).__init__() - license = get_licenser().validate() + license = get_licenser().validate(new_cert=False) if options.get('data'): - if license.get('license_key', '') != 'UNLICENSED': - license['license_key'] = '********' return json.dumps(license) return license.get('license_type', 'none') diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index f4431b2705..9f97faf45e 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -901,7 +901,7 @@ class Command(BaseCommand): )) def check_license(self): - license_info = get_licenser().validate() + license_info = get_licenser().validate(new_cert=False) local_license_type = license_info.get('license_type', 'UNLICENSED') if license_info.get('license_key', 'UNLICENSED') == 'UNLICENSED': logger.error(LICENSE_NON_EXISTANT_MESSAGE) @@ -938,7 +938,7 @@ class Command(BaseCommand): logger.warning(LICENSE_MESSAGE % d) def check_org_host_limit(self): - license_info = get_licenser().validate() + license_info = get_licenser().validate(new_cert=False) if license_info.get('license_type', 'UNLICENSED') == 'open': return diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 19669a0f27..2a1c85e23d 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2160,7 +2160,7 @@ class RunProjectUpdate(BaseTask): 'local_path': os.path.basename(project_update.project.local_path), 'project_path': project_update.get_project_path(check_if_exists=False), # deprecated 'insights_url': settings.INSIGHTS_URL_BASE, - 'awx_license_type': get_license(show_key=False).get('license_type', 'UNLICENSED'), + 'awx_license_type': get_license().get('license_type', 'UNLICENSED'), 'awx_version': get_awx_version(), 'scm_url': scm_url, 'scm_branch': scm_branch, diff --git a/awx/main/tests/functional/core/test_licenses.py b/awx/main/tests/functional/core/test_licenses.py index f59318502c..ba8b1ff9af 100644 --- a/awx/main/tests/functional/core/test_licenses.py +++ b/awx/main/tests/functional/core/test_licenses.py @@ -1,13 +1,16 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -from awx.main.utils.common import StubLicense +# 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' -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' +# Test license_date is always seconds diff --git a/awx/main/tests/functional/test_licenses.py b/awx/main/tests/functional/test_licenses.py index 6c34321f8d..f3e623d281 100644 --- a/awx/main/tests/functional/test_licenses.py +++ b/awx/main/tests/functional/test_licenses.py @@ -30,8 +30,7 @@ def test_python_and_js_licenses(): # Check variations of '-' and '_' in filenames due to python for fname in [name, name.replace('-','_')]: if entry.startswith(fname) and entry.endswith('.tar.gz'): - entry = entry[:-7] - (n, v) = entry.rsplit('-',1) + v = entry.split(name + '-')[1].split('.tar.gz')[0] return v return None diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index b49af2efd0..93ef41a190 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -39,6 +39,8 @@ from awx.main import tasks from awx.main.utils import encrypt_field, encrypt_value from awx.main.utils.safe_yaml import SafeLoader +from awx.main.utils.licensing import Licenser + class TestJobExecution(object): EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----' @@ -1830,7 +1832,10 @@ class TestProjectUpdateGalaxyCredentials(TestJobExecution): task = RunProjectUpdate() env = task.build_env(project_update, private_data_dir) - task.build_extra_vars_file(project_update, private_data_dir) + + with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}): + task.build_extra_vars_file(project_update, private_data_dir) + assert task.__vars__['roles_enabled'] is False assert task.__vars__['collections_enabled'] is False for k in env: @@ -1850,7 +1855,10 @@ class TestProjectUpdateGalaxyCredentials(TestJobExecution): project_update.project.organization.galaxy_credentials.add(public_galaxy) task = RunProjectUpdate() env = task.build_env(project_update, private_data_dir) - task.build_extra_vars_file(project_update, private_data_dir) + + with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}): + task.build_extra_vars_file(project_update, private_data_dir) + assert task.__vars__['roles_enabled'] is True assert task.__vars__['collections_enabled'] is True assert sorted([ @@ -1935,7 +1943,9 @@ class TestProjectUpdateCredentials(TestJobExecution): assert settings.PROJECTS_ROOT in process_isolation['process_isolation_show_paths'] task._write_extra_vars_file = mock.Mock() - task.build_extra_vars_file(project_update, private_data_dir) + + with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}): + task.build_extra_vars_file(project_update, private_data_dir) call_args, _ = task._write_extra_vars_file.call_args_list[0] _, extra_vars = call_args diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index a65120c8e8..f628d8f831 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -55,8 +55,7 @@ __all__ = [ 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices', 'get_external_account', 'task_manager_bulk_reschedule', - 'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout', - 'StubLicense' + 'schedule_task_manager', 'classproperty', 'create_temporary_fifo', 'truncate_stdout' ] @@ -190,7 +189,7 @@ def get_awx_version(): def get_awx_http_client_headers(): - license = get_license(show_key=False).get('license_type', 'UNLICENSED') + license = get_license().get('license_type', 'UNLICENSED') headers = { 'Content-Type': 'application/json', 'User-Agent': '{} {} ({})'.format( @@ -202,34 +201,14 @@ def get_awx_http_client_headers(): return headers -class StubLicense(object): - - features = { - 'activity_streams': True, - 'ha': True, - 'ldap': True, - 'multiple_organizations': True, - 'surveys': True, - 'system_tracking': True, - 'rebranding': True, - 'enterprise_auth': True, - 'workflows': True, - } - - def validate(self): - return dict(license_key='OPEN', - valid_key=True, - compliant=True, - features=self.features, - license_type='open') - - +# Update all references of this function in the codebase to just import `from awx.main.utils.licensing import Licenser` directly def get_licenser(*args, **kwargs): try: - from tower_license import TowerLicense - return TowerLicense(*args, **kwargs) - except ImportError: - return StubLicense(*args, **kwargs) + # Get License Config from db? + from awx.main.utils.licensing import Licenser + return Licenser(*args, **kwargs) + except Exception as e: + raise ValueError(_('Error importing Tower License: %s') % e) def update_scm_url(scm_type, url, username=True, password=True, diff --git a/awx/main/utils/licensing.py b/awx/main/utils/licensing.py new file mode 100644 index 0000000000..1021dcbb8e --- /dev/null +++ b/awx/main/utils/licensing.py @@ -0,0 +1,382 @@ +# 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 os +import configparser +from datetime import datetime +import collections +import copy +import tempfile +import logging +import re +import requests +import time + +# Django +from django.conf import settings + +# AWX +from awx.main.models import Host + +# RHSM +from rhsm import certificate + +MAX_INSTANCES = 9999999 + +logger = logging.getLogger(__name__) + + +def rhsm_config(): + config = configparser.ConfigParser() + config.read('/etc/rhsm/rhsm.conf') + return config + + +class Licenser(object): + # warn when there is a month (30 days) left on the license + LICENSE_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 Tower", + 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 self._check_product_cert(): + if 'license_key' in self._attrs: + self._unset_attrs() + if 'valid_key' in self._attrs: + if not self._attrs['valid_key']: + self._unset_attrs() + else: + self._unset_attrs() + else: + self._generate_open_config() + + + def _check_product_cert(self): + # Product Cert Name: ansible-tower-3.7-rhel-7.x86_64.pem + # Maybe check validity of Product Cert somehow? + if os.path.exists('/etc/tower/certs') and os.path.exists('/var/lib/awx/.tower_version'): + return True + return False + + + def _generate_open_config(self): + self._attrs.update(dict(license_type='open', + valid_key=True, + subscription_name='OPEN', + product_name="AWX", + )) + settings.LICENSE = self._attrs + + + def _unset_attrs(self): + self._attrs = self.UNLICENSED_DATA.copy() + + + def _clear_license_setting(self): + self.unset_attrs() + settings.LICENSE = {} + + + def _generate_product_config(self): + raw_cert = getattr(settings, 'ENTITLEMENT_CERT', None) + # Fail early if no entitlement cert is available + if not raw_cert or raw_cert == '': + self._clear_license_setting() + return + + # Read certificate + certinfo = certificate.create_from_pem(raw_cert) + if not certinfo.is_valid(): + raise ValueError("Could not parse entitlement certificate") + if certinfo.is_expired(): + raise ValueError("Certificate is expired") + if not any(map(lambda x: x.id == '480', certinfo.products)): + self._clear_license_setting() + raise ValueError("Certificate is for another product") + + # Parse output for subscription metadata to build config + license = dict() + license['sku'] = certinfo.order.sku + license['instance_count'] = int(certinfo.order.quantity_used) + license['support_level'] = certinfo.order.service_level + license['subscription_name'] = certinfo.order.name + license['pool_id'] = certinfo.pool.id + license['license_date'] = certinfo.end.strftime('%s') + license['product_name'] = certinfo.order.name + 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): + host = 'https://' + str(self.config.get("server", "hostname")) + 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', False) + 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: + raise OSError('Unable to read rhsm config to get ca_cert location. {}'.format(str(e))) + 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['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='Ansible Tower by Red Hat') + 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['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.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, new_cert=False): + + # Generate Config from Entitlement cert if it exists + if new_cert: + self._generate_product_config() + + # 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 + attrs['free_instances'] = max(0, available_instances - current_instances) + + license_date = int(attrs.get('license_date', None) 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 attrs['free_instances'] >= 0) + attrs['date_warning'] = bool(time_remaining < self.LICENSE_TIMEOUT) + attrs['date_expired'] = bool(time_remaining <= 0) + return attrs diff --git a/awx/main/validators.py b/awx/main/validators.py index 879be056e5..d751b51cbe 100644 --- a/awx/main/validators.py +++ b/awx/main/validators.py @@ -17,6 +17,12 @@ from rest_framework.exceptions import ParseError from awx.main.utils import parse_yaml_or_json +def validate_entitlement_cert(data, min_keys=0, max_keys=None, min_certs=0, max_certs=None): + # TODO: replace with more actual robust logic here to allow for multiple certificates in one cert file (because this is how entitlements do) + # This is a temporary hack that should not be merged. Look at Ln:92 + pass + + def validate_pem(data, min_keys=0, max_keys=None, min_certs=0, max_certs=None): """ Validate the given PEM data is valid and contains the required numbers of diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 618f5282a4..707f40aa00 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -667,6 +667,10 @@ EC2_ENABLED_VALUE = 'running' EC2_INSTANCE_ID_VAR = 'ec2_id' EC2_EXCLUDE_EMPTY_GROUPS = True +# Entitlements +ENTITLEMENT_CERT = '' + + # ------------ # -- VMware -- # ------------ diff --git a/awx/ui/client/src/configuration/forms/settings-form.route.js b/awx/ui/client/src/configuration/forms/settings-form.route.js index 20ca06fd7f..ea0d04b7e4 100644 --- a/awx/ui/client/src/configuration/forms/settings-form.route.js +++ b/awx/ui/client/src/configuration/forms/settings-form.route.js @@ -54,20 +54,20 @@ export default { }); }], resolve: { - rhCreds: ['Rest', 'GetBasePath', function(Rest, GetBasePath) { + subscriptionCreds: ['Rest', 'GetBasePath', function(Rest, GetBasePath) { Rest.setUrl(`${GetBasePath('settings')}system/`); return Rest.get() .then(({data}) => { - const rhCreds = {}; + const subscriptionCreds = {}; if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") { - rhCreds.REDHAT_USERNAME = data.REDHAT_USERNAME; + subscriptionCreds.REDHAT_USERNAME = data.REDHAT_USERNAME; } if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { - rhCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD; + subscriptionCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD; } - return rhCreds; + return subscriptionCreds; }).catch(() => { return {}; }); diff --git a/awx/ui/client/src/license/checkLicense.factory.js b/awx/ui/client/src/license/checkLicense.factory.js index e76f1ea0b7..73fdc5e15e 100644 --- a/awx/ui/client/src/license/checkLicense.factory.js +++ b/awx/ui/client/src/license/checkLicense.factory.js @@ -15,14 +15,26 @@ export default return config.license_info; }, - post: function(payload, eula){ - var defaultUrl = GetBasePath('config'); + post: function(payload, eula, attach){ + var defaultUrl = GetBasePath('config') + (attach ? 'attach/' : ''); Rest.setUrl(defaultUrl); var data = payload; - data.eula_accepted = eula; + + if (!attach) { + data.eula_accepted = eula; + } return Rest.post(JSON.stringify(data)) .then((response) =>{ + if (attach) { + var configPayload = {}; + configPayload.eula_accepted = eula; + Rest.setUrl(GetBasePath('config')); + return Rest.post(configPayload) + .then((configResponse) => { + return configResponse.data; + }); + } return response.data; }) .catch(({data}) => { diff --git a/awx/ui/client/src/license/license.controller.js b/awx/ui/client/src/license/license.controller.js index 37c1c38364..00d00565ab 100644 --- a/awx/ui/client/src/license/license.controller.js +++ b/awx/ui/client/src/license/license.controller.js @@ -8,9 +8,9 @@ import {N_} from "../i18n"; export default ['Wait', '$state', '$scope', '$rootScope', 'ProcessErrors', 'CheckLicense', 'moment', '$timeout', 'Rest', 'LicenseStrings', - '$window', 'ConfigService', 'pendoService', 'insightsEnablementService', 'i18n', 'config', 'rhCreds', 'GetBasePath', + '$window', 'ConfigService', 'pendoService', 'insightsEnablementService', 'i18n', 'config', 'subscriptionCreds', 'GetBasePath', function(Wait, $state, $scope, $rootScope, ProcessErrors, CheckLicense, moment, $timeout, Rest, LicenseStrings, - $window, ConfigService, pendoService, insightsEnablementService, i18n, config, rhCreds, GetBasePath) { + $window, ConfigService, pendoService, insightsEnablementService, i18n, config, subscriptionCreds, GetBasePath) { $scope.strings = LicenseStrings; @@ -35,7 +35,7 @@ export default const reset = function() { $scope.newLicense.eula = undefined; - $scope.rhCreds = {}; + $scope.subscriptionCreds = {}; $scope.selectedLicense = {}; }; @@ -44,9 +44,9 @@ export default $scope.fileName = N_("No file selected."); if ($rootScope.licenseMissing) { - $scope.title = $rootScope.BRAND_NAME + i18n._(" License"); + $scope.title = $rootScope.BRAND_NAME + i18n._(" Subscription"); } else { - $scope.title = i18n._("License Management"); + $scope.title = i18n._("Subscription Management"); } $scope.license = config; @@ -62,32 +62,41 @@ export default insights: true }; - $scope.rhCreds = {}; + $scope.subscriptionCreds = {}; - if (rhCreds.REDHAT_USERNAME && rhCreds.REDHAT_USERNAME !== "") { - $scope.rhCreds.username = rhCreds.REDHAT_USERNAME; + if (subscriptionCreds.SUBSCRIPTIONS_USERNAME && subscriptionCreds.SUBSCRIPTIONS_USERNAME !== "") { + $scope.subscriptionCreds.username = subscriptionCreds.SUBSCRIPTIONS_USERNAME; } - if (rhCreds.REDHAT_PASSWORD && rhCreds.REDHAT_PASSWORD !== "") { - $scope.rhCreds.password = rhCreds.REDHAT_PASSWORD; + if (subscriptionCreds.SUBSCRIPTIONS_PASSWORD && subscriptionCreds.SUBSCRIPTIONS_PASSWORD !== "") { + $scope.subscriptionCreds.password = subscriptionCreds.SUBSCRIPTIONS_PASSWORD; + $scope.showPlaceholderPassword = true; + } + + if (subscriptionCreds.ORGANIZATION_ID && subscriptionCreds.ORGANIZATION_ID !== "") { + $scope.subscriptionCreds.organization_id = subscriptionCreds.ORGANIZATION_ID; $scope.showPlaceholderPassword = true; } }; - const updateRHCreds = (config) => { + const updateSubscriptionCreds = (config) => { Rest.setUrl(`${GetBasePath('settings')}system/`); Rest.get() .then(({data}) => { initVars(config); - if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") { - $scope.rhCreds.username = data.REDHAT_USERNAME; + if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") { + $scope.subscriptionCreds.username = data.SUBSCRIPTIONS_USERNAME; } - if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { - $scope.rhCreds.password = data.REDHAT_PASSWORD; + if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") { + $scope.subscriptionCreds.password = data.SUBSCRIPTIONS_PASSWORD; $scope.showPlaceholderPassword = true; } + + if (data.ENTITLEMENT_CONSUMER && data.ENTITLEMENT_CONSUMER.org && data.ENTITLEMENT_CONSUMER.org !== "") { + $scope.subscriptionCreds.organization_id = data.ENTITLEMENT_CONSUMER.org; + } }).catch(() => { initVars(config); }); @@ -100,28 +109,23 @@ export default $scope.fileName = event.target.files[0].name; // Grab the key from the raw license file const raw = new FileReader(); - // readAsFoo runs async + raw.onload = function() { - try { - $scope.newLicense.file = JSON.parse(raw.result); - } catch(err) { - ProcessErrors($rootScope, null, null, null, - {msg: i18n._('Invalid file format. Please upload valid JSON.')}); - } + $scope.newLicense.entitlement_cert = raw.result; }; try { raw.readAsText(event.target.files[0]); } catch(err) { ProcessErrors($rootScope, null, null, null, - {msg: i18n._('Invalid file format. Please upload valid JSON.')}); + {msg: i18n._('Invalid file format. Please upload a certificate/key pair')}); } }; // HTML5 spec doesn't provide a way to customize file input css // So we hide the default input, show our own, and simulate clicks to the hidden input $scope.fakeClick = function() { - if($scope.user_is_superuser && (!$scope.rhCreds.username || $scope.rhCreds.username === '') && (!$scope.rhCreds.password || $scope.rhCreds.password === '')) { + if($scope.user_is_superuser && (!$scope.subscriptionCreds.username || $scope.subscriptionCreds.username === '') && (!$scope.subscriptionCreds.password || $scope.subscriptionCreds.password === '')) { $('#License-file').click(); } }; @@ -131,9 +135,9 @@ export default }; $scope.replacePassword = () => { - if ($scope.user_is_superuser && !$scope.newLicense.file) { + if ($scope.user_is_superuser && !$scope.newLicense.entitlement_cert) { $scope.showPlaceholderPassword = false; - $scope.rhCreds.password = ""; + $scope.subscriptionCreds.password = ""; $timeout(() => { $('.tooltip').remove(); $('#rh-password').focus(); @@ -142,9 +146,9 @@ export default }; $scope.lookupLicenses = () => { - if ($scope.rhCreds.username && $scope.rhCreds.password) { + if ($scope.subscriptionCreds.username && $scope.subscriptionCreds.password) { Wait('start'); - ConfigService.getSubscriptions($scope.rhCreds.username, $scope.rhCreds.password) + ConfigService.getSubscriptions($scope.subscriptionCreds.username, $scope.subscriptionCreds.password) .then(({data}) => { Wait('stop'); if (data && data.length > 0) { @@ -172,29 +176,31 @@ export default $scope.confirmLicenseSelection = () => { $scope.showLicenseModal = false; $scope.selectedLicense.fullLicense = $scope.rhLicenses.find((license) => { - return license.license_key === $scope.selectedLicense.modalKey; + return license.pool_id === $scope.selectedLicense.modalPoolId; }); - $scope.selectedLicense.modalKey = undefined; + $scope.selectedLicense.modalPoolId = undefined; }; $scope.cancelLicenseLookup = () => { $scope.showLicenseModal = false; - $scope.selectedLicense.modalKey = undefined; + $scope.selectedLicense.modalPoolId = undefined; }; $scope.submit = function() { Wait('start'); let payload = {}; - if ($scope.newLicense.file) { - payload = $scope.newLicense.file; + let attach = false; + if ($scope.newLicense.entitlement_cert) { + payload.entitlement_cert = $scope.newLicense.entitlement_cert; } else if ($scope.selectedLicense.fullLicense) { - payload = $scope.selectedLicense.fullLicense; + payload.pool_id = $scope.selectedLicense.fullLicense.pool_id; + payload.org = $scope.subscriptionCreds.organization_id; + attach = true; } - CheckLicense.post(payload, $scope.newLicense.eula) - .then((licenseInfo) => { + CheckLicense.post(payload, $scope.newLicense.eula, attach) + .finally((licenseInfo) => { reset(); - ConfigService.delete(); ConfigService.getConfig(licenseInfo) .then(function(config) { @@ -217,7 +223,7 @@ export default licenseMissing: false }); } else { - updateRHCreds(config); + updateSubscriptionCreds(config); $scope.success = true; $rootScope.licenseMissing = false; // for animation purposes diff --git a/awx/ui/client/src/license/license.partial.html b/awx/ui/client/src/license/license.partial.html index 2dc7beeb9c..6958a536e0 100644 --- a/awx/ui/client/src/license/license.partial.html +++ b/awx/ui/client/src/license/license.partial.html @@ -5,10 +5,10 @@
Details
-
License
+
Subscription
- Valid License - Invalid License + Valid + Invalid
@@ -18,7 +18,7 @@
-
License Type
+
Subscription Type
{{license.license_info.license_type}}
@@ -29,12 +29,6 @@ {{license.license_info.subscription_name}}
-
-
License Key
-
- {{license.license_info.license_key}} -
-
Expires On
@@ -79,7 +73,7 @@
{{title}}
-
Welcome to Ansible Tower! Please complete the steps below to acquire a license.
+
Welcome to Ansible Tower! Please complete the steps below to acquire a subscription.
@@ -88,12 +82,12 @@ 1 - Please click the button below to visit Ansible's website to get a Tower license key. + Please click the button below to visit Ansible's website to get a Tower subscription.
@@ -101,16 +95,16 @@ 2 - Choose your license file, agree to the End User License Agreement, and click submit. + Choose your entitlement certificate and key pair, agree to the End User License Agreement, and click submit.
* - License + Entitlement certificate and key
-
Upload a license file
+
Upload an entitlement certificate and key pair
- Browse + Browse {{fileName|translate}}
@@ -125,12 +119,13 @@
- 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. +<<<<<<< HEAD + Provide your Red Hat or Satellite credentials below and you can choose from a list of your available subscriptions. If you are a Satellite user please specify the organization ID you wish to associate this Tower Installation to. The credentials you use will be stored for future use in retrieving renewal or expanded subscriptions.
- +
@@ -143,11 +138,15 @@
- +
+
+ + +
- GET LICENSES + GET LICENSES
@@ -200,7 +199,7 @@ Save successful!
- +
@@ -223,12 +222,12 @@