diff --git a/.gitignore b/.gitignore index 7e0f07a83c..9e7a41e6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,6 @@ awx/ui_next/coverage/ awx/ui_next/build awx/ui_next/.env.local rsyslog.pid -/tower-license -/tower-license/** tools/prometheus/data tools/docker-compose/Dockerfile @@ -147,3 +145,4 @@ use_dev_supervisor.txt .idea/* *.unison.tmp *.# +/tools/docker-compose/overrides/ diff --git a/Makefile b/Makefile index ca76648499..bdcbb19356 100644 --- a/Makefile +++ b/Makefile @@ -646,9 +646,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..ac9ab03907 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 @@ -189,7 +187,8 @@ class APIView(views.APIView): ''' Log warning for 400 requests. Add header with elapsed time. ''' - + from awx.main.utils import get_licenser + from awx.main.utils.licensing import OpenLicense # # If the URL was rewritten, and we get a 404, we should entirely # replace the view in the request context with an ApiErrorView() @@ -225,7 +224,8 @@ class APIView(views.APIView): response = super(APIView, self).finalize_response(request, response, *args, **kwargs) time_started = getattr(self, 'time_started', None) response['X-API-Product-Version'] = get_awx_version() - response['X-API-Product-Name'] = 'AWX' if isinstance(get_licenser(), StubLicense) else 'Red Hat Ansible Tower' + response['X-API-Product-Name'] = 'AWX' if isinstance(get_licenser(), OpenLicense) else 'Red Hat Ansible Tower' + response['X-API-Node'] = settings.CLUSTER_HOST_ID if time_started: time_elapsed = time.time() - self.time_started 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 4f436c8f0e..76c9e07725 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..0f5e7e6cdd 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -1,9 +1,10 @@ # Copyright (c) 2018 Ansible, Inc. # All Rights Reserved. +import base64 +import json import logging import operator -import json from collections import OrderedDict from django.conf import settings @@ -29,8 +30,8 @@ from awx.main.utils import ( get_custom_venv_choices, to_python_boolean, ) +from awx.main.utils.licensing import validate_entitlement_manifest from awx.api.versioning import reverse, drf_reverse -from awx.conf.license import get_license from awx.main.constants import PRIVILEGE_ESCALATION_METHODS from awx.main.models import ( Project, @@ -178,7 +179,7 @@ class ApiV2PingView(APIView): class ApiV2SubscriptionView(APIView): permission_classes = (IsAuthenticated,) - name = _('Configuration') + name = _('Subscriptions') swagger_topic = 'System Configuration' def check_permissions(self, request): @@ -189,18 +190,18 @@ class ApiV2SubscriptionView(APIView): def post(self, request): from awx.main.utils.common import get_licenser data = request.data.copy() - if data.get('rh_password') == '$encrypted$': - data['rh_password'] = settings.REDHAT_PASSWORD + if data.get('subscriptions_password') == '$encrypted$': + data['subscriptions_password'] = settings.SUBSCRIPTIONS_PASSWORD try: - user, pw = data.get('rh_username'), data.get('rh_password') + user, pw = data.get('subscriptions_username'), data.get('subscriptions_password') with set_environ(**settings.AWX_TASK_ENV): validated = get_licenser().validate_rh(user, pw) if user: - settings.REDHAT_USERNAME = data['rh_username'] + settings.SUBSCRIPTIONS_USERNAME = data['subscriptions_username'] if pw: - settings.REDHAT_PASSWORD = data['rh_password'] + settings.SUBSCRIPTIONS_PASSWORD = data['subscriptions_password'] except Exception as exc: - msg = _("Invalid License") + msg = _("Invalid Subscription") if ( isinstance(exc, requests.exceptions.HTTPError) and getattr(getattr(exc, 'response', None), 'status_code', None) == 401 @@ -213,13 +214,63 @@ class ApiV2SubscriptionView(APIView): elif isinstance(exc, (ValueError, OSError)) and exc.args: msg = exc.args[0] else: - logger.exception(smart_text(u"Invalid license submitted."), + logger.exception(smart_text(u"Invalid subscription submitted."), extra=dict(actor=request.user.username)) return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST) return Response(validated) +class ApiV2AttachView(APIView): + + permission_classes = (IsAuthenticated,) + name = _('Attach Subscription') + swagger_topic = 'System Configuration' + + def check_permissions(self, request): + super(ApiV2AttachView, self).check_permissions(request) + if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}: + self.permission_denied(request) # Raises PermissionDenied exception. + + def post(self, request): + data = request.data.copy() + pool_id = data.get('pool_id', None) + if not pool_id: + return Response({"error": _("No subscription pool ID provided.")}, status=status.HTTP_400_BAD_REQUEST) + user = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None) + pw = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None) + if pool_id and user and pw: + from awx.main.utils.common import get_licenser + data = request.data.copy() + try: + with set_environ(**settings.AWX_TASK_ENV): + validated = get_licenser().validate_rh(user, pw) + except Exception as exc: + msg = _("Invalid Subscription") + if ( + isinstance(exc, requests.exceptions.HTTPError) and + getattr(getattr(exc, 'response', None), 'status_code', None) == 401 + ): + msg = _("The provided credentials are invalid (HTTP 401).") + elif isinstance(exc, requests.exceptions.ProxyError): + msg = _("Unable to connect to proxy server.") + elif isinstance(exc, requests.exceptions.ConnectionError): + msg = _("Could not connect to subscription service.") + elif isinstance(exc, (ValueError, OSError)) and exc.args: + msg = exc.args[0] + else: + logger.exception(smart_text(u"Invalid subscription submitted."), + extra=dict(actor=request.user.username)) + return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST) + for sub in validated: + if sub['pool_id'] == pool_id: + sub['valid_key'] = True + settings.LICENSE = sub + return Response(sub) + + return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST) + + class ApiV2ConfigView(APIView): permission_classes = (IsAuthenticated,) @@ -234,15 +285,11 @@ class ApiV2ConfigView(APIView): def get(self, request, format=None): '''Return various sitewide configuration settings''' - if request.user.is_superuser or request.user.is_system_auditor: - license_data = get_license(show_key=True) - else: - license_data = get_license(show_key=False) + from awx.main.utils.common import get_licenser + license_data = get_licenser().validate() + if not license_data.get('valid_key', False): license_data = {} - if license_data and 'features' in license_data and 'activity_streams' in license_data['features']: - # FIXME: Make the final setting value dependent on the feature? - license_data['features']['activity_streams'] &= settings.ACTIVITY_STREAM_ENABLED pendo_state = settings.PENDO_TRACKING_STATE if settings.PENDO_TRACKING_STATE in ('off', 'anonymous', 'detailed') else 'off' @@ -281,9 +328,10 @@ class ApiV2ConfigView(APIView): return Response(data) + def post(self, request): if not isinstance(request.data, dict): - return Response({"error": _("Invalid license data")}, status=status.HTTP_400_BAD_REQUEST) + return Response({"error": _("Invalid subscription data")}, status=status.HTTP_400_BAD_REQUEST) if "eula_accepted" not in request.data: return Response({"error": _("Missing 'eula_accepted' property")}, status=status.HTTP_400_BAD_REQUEST) try: @@ -300,25 +348,47 @@ class ApiV2ConfigView(APIView): logger.info(smart_text(u"Invalid JSON submitted for license."), extra=dict(actor=request.user.username)) return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST) - try: - from awx.main.utils.common import get_licenser - license_data = json.loads(data_actual) - license_data_validated = get_licenser(**license_data).validate() - except Exception: - logger.warning(smart_text(u"Invalid license submitted."), - extra=dict(actor=request.user.username)) - return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) + + from awx.main.utils.common import get_licenser + license_data = json.loads(data_actual) + if 'license_key' in license_data: + return Response({"error": _('Legacy license submitted. A subscription manifest is now required.')}, status=status.HTTP_400_BAD_REQUEST) + if 'manifest' in license_data: + try: + json_actual = json.loads(base64.b64decode(license_data['manifest'])) + if 'license_key' in json_actual: + return Response( + {"error": _('Legacy license submitted. A subscription manifest is now required.')}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception: + pass + try: + license_data = validate_entitlement_manifest(license_data['manifest']) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception: + logger.exception('Invalid manifest submitted. {}') + return Response({"error": _('Invalid manifest submitted.')}, status=status.HTTP_400_BAD_REQUEST) + + try: + license_data_validated = get_licenser().license_from_manifest(license_data) + except Exception: + logger.warning(smart_text(u"Invalid subscription submitted."), + extra=dict(actor=request.user.username)) + return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) + else: + license_data_validated = get_licenser().validate() # If the license is valid, write it to the database. if license_data_validated['valid_key']: - settings.LICENSE = license_data if not settings_registry.is_setting_read_only('TOWER_URL_BASE'): settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host()) return Response(license_data_validated) - logger.warning(smart_text(u"Invalid license submitted."), + logger.warning(smart_text(u"Invalid subscription submitted."), extra=dict(actor=request.user.username)) - return Response({"error": _("Invalid license")}, status=status.HTTP_400_BAD_REQUEST) + return Response({"error": _("Invalid subscription")}, status=status.HTTP_400_BAD_REQUEST) def delete(self, request): try: diff --git a/awx/conf/license.py b/awx/conf/license.py index 6ad1042f9a..3929c37921 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 + from awx.main.utils import get_licenser return get_licenser().validate() -def get_license(show_key=False): +def get_license(): """Return a dictionary representing the active license on this Tower instance.""" - license_data = _get_validated_license_data() - if not show_key: - license_data.pop('license_key', None) - return license_data + return _get_validated_license_data() diff --git a/awx/conf/migrations/0008_subscriptions.py b/awx/conf/migrations/0008_subscriptions.py new file mode 100644 index 0000000000..dacd066b4d --- /dev/null +++ b/awx/conf/migrations/0008_subscriptions.py @@ -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) + ] diff --git a/awx/conf/migrations/_subscriptions.py b/awx/conf/migrations/_subscriptions.py new file mode 100644 index 0000000000..2b979fb68e --- /dev/null +++ b/awx/conf/migrations/_subscriptions.py @@ -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) 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/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..6bf86db214 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -1,7 +1,5 @@ # Python -import json import logging -import os # Django from django.utils.translation import ugettext_lazy as _ @@ -13,6 +11,7 @@ from rest_framework.fields import FloatField # Tower from awx.conf import fields, register, register_validate + logger = logging.getLogger('awx.main.conf') register( @@ -92,22 +91,10 @@ register( ) -def _load_default_license_from_file(): - try: - license_file = os.environ.get('AWX_LICENSE_FILE', '/etc/tower/license') - if os.path.exists(license_file): - license_data = json.load(open(license_file)) - logger.debug('Read license data from "%s".', license_file) - return license_data - except Exception: - logger.warning('Could not read license from "%s".', license_file, exc_info=True) - return {} - - register( 'LICENSE', field_class=fields.DictField, - default=_load_default_license_from_file, + default=lambda: {}, label=_('License'), help_text=_('The license controls which features and functionality are ' 'enabled. Use /api/v2/config/ to update or change ' @@ -124,7 +111,7 @@ register( encrypted=False, read_only=False, label=_('Red Hat customer username'), - help_text=_('This username is used to retrieve license information and to send Automation Analytics'), # noqa + help_text=_('This username is used to send data to Automation Analytics'), category=_('System'), category_slug='system', ) @@ -137,7 +124,33 @@ register( encrypted=True, read_only=False, label=_('Red Hat customer password'), - help_text=_('This password is used to retrieve license information and to send Automation Analytics'), # noqa + help_text=_('This password is used to send data to Automation Analytics'), + category=_('System'), + category_slug='system', +) + +register( + 'SUBSCRIPTIONS_USERNAME', + field_class=fields.CharField, + default='', + allow_blank=True, + encrypted=False, + read_only=False, + label=_('Red Hat or Satellite username'), + help_text=_('This username is used to retrieve subscription and content information'), # noqa + category=_('System'), + category_slug='system', +) + +register( + 'SUBSCRIPTIONS_PASSWORD', + field_class=fields.CharField, + default='', + allow_blank=True, + encrypted=True, + read_only=False, + label=_('Red Hat or Satellite password'), + help_text=_('This password is used to retrieve subscription and content information'), # noqa category=_('System'), category_slug='system', ) diff --git a/awx/main/management/commands/check_license.py b/awx/main/management/commands/check_license.py index 8c0798cc53..356ab42249 100644 --- a/awx/main/management/commands/check_license.py +++ b/awx/main/management/commands/check_license.py @@ -18,7 +18,5 @@ class Command(BaseCommand): super(Command, self).__init__() license = get_licenser().validate() if options.get('data'): - if license.get('license_key', '') != 'UNLICENSED': - license['license_key'] = '********' return json.dumps(license) return license.get('license_type', 'none') diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index f4431b2705..c92215560e 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -903,7 +903,7 @@ class Command(BaseCommand): def check_license(self): license_info = get_licenser().validate() local_license_type = license_info.get('license_type', 'UNLICENSED') - if license_info.get('license_key', 'UNLICENSED') == 'UNLICENSED': + if local_license_type == 'UNLICENSED': logger.error(LICENSE_NON_EXISTANT_MESSAGE) raise CommandError('No license found!') elif local_license_type == 'open': diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 0e03055055..498a85ad9f 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/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index 67c9868649..8a1f50035f 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -4,7 +4,6 @@ # Python import pytest -import os from django.conf import settings @@ -19,15 +18,6 @@ TEST_PNG_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACEAAAAjCAYAAAAaL TEST_JPEG_LOGO = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBkRXhpZgAATU0AKgAAAAgAAwEGAAMAAAABAAIAAAESAAMAAAABAAEAAIdpAAQAAAABAAAAMgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIaADAAQAAAABAAAAIwAAAAD/4QkhaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA1LjQuMCI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiLz4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+AP/tADhQaG90b3Nob3AgMy4wADhCSU0EBAAAAAAAADhCSU0EJQAAAAAAENQdjNmPALIE6YAJmOz4Qn7/wAARCAAjACEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9sAQwAGBgYGBgYKBgYKDgoKCg4SDg4ODhIXEhISEhIXHBcXFxcXFxwcHBwcHBwcIiIiIiIiJycnJycsLCwsLCwsLCws/9sAQwEHBwcLCgsTCgoTLh8aHy4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4u/90ABAAD/9oADAMBAAIRAxEAPwD6poormvFfivSvB2lHVtWLGMtsRE2hnYKzlVLsi52oxALDdjauWKqQCXQfFXh7xP8Aaf7AvYrz7HL5U3lk/K3YjIGVODtcZVsHBODXQV806bcT+E9L03XbCOS2udMsLQanbB4po72xYMfOQpKYyV2zPEwcNwVK7WAr6WriwWMWIUvdcZRdmnuu33rVFSjYKKKK7ST/0PqmuF8Vv4X8S+HNZ0+e/gIsYJvtEsL+bJZsI3UuyxNvBA3gpxvXchyCRXdV8ta3bW667DoloW1y10tLLTJxZWP2hoLSGYzNHclGZpJC0ESk8IAZcRB8is61T2cHK1/1DrY526h8YXHh691vxCz6dafY5Q0U7yGSeQxSxohNzJLcbUeQ4VnVNxBRCWL19b2eraVqE9xa2F3BcS2jbJ0ikV2ibJG1wpJU5UjBx0PpXzrrniy4k17TrrWrGex022ufMijvd9m11PGH8naXKqsUcgR3MhB5U7MA16x4L8F3vhq2sY9Ru4rg6day2tusEAhCrcOkknmEMRI2Y1AcLGT8xYMzZHjZFGu6cquKjaUnt2XS76vv/SN8RVjOdoKyXY9Cooor3TA//9H6pr4gfxRrMvxJ0/whLJE+maVrcVnZRtBCzwQQ3SIipMU80fKignflgPmJr7fr4A/5rf8A9zJ/7eUAdX8SfGviPwl8TtaPh6eK1eTyN0n2eCSUg28OV8ySNn2/KDtztzzjNfZVhY2umWMGm2KeXb2sSQxJknakYCqMkknAHUnNfBXxt/5Kdq//AG7/APpPFX3/AEAFFFFAH//Z' # NOQA -@pytest.fixture -def mock_no_license_file(mocker): - ''' - Ensures that tests don't pick up dev container license file - ''' - os.environ['AWX_LICENSE_FILE'] = '/does_not_exist' - return None - - @pytest.mark.django_db def test_url_base_defaults_to_request(options, admin): # If TOWER_URL_BASE is not set, default to the Tower request hostname diff --git a/awx/main/tests/functional/core/test_licenses.py b/awx/main/tests/functional/core/test_licenses.py deleted file mode 100644 index f59318502c..0000000000 --- a/awx/main/tests/functional/core/test_licenses.py +++ /dev/null @@ -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' - 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..00fc73c631 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,15 @@ def get_awx_http_client_headers(): return headers -class StubLicense(object): - - features = { - 'activity_streams': True, - 'ha': True, - 'ldap': True, - 'multiple_organizations': True, - 'surveys': True, - 'system_tracking': True, - 'rebranding': True, - 'enterprise_auth': True, - 'workflows': True, - } - - def validate(self): - return dict(license_key='OPEN', - valid_key=True, - compliant=True, - features=self.features, - license_type='open') - - def get_licenser(*args, **kwargs): + from awx.main.utils.licensing import Licenser, OpenLicense try: - from tower_license import TowerLicense - return TowerLicense(*args, **kwargs) - except ImportError: - return StubLicense(*args, **kwargs) + if os.path.exists('/var/lib/awx/.tower_version'): + return Licenser(*args, **kwargs) + else: + return OpenLicense() + except Exception as e: + raise ValueError(_('Error importing Tower License: %s') % e) def update_scm_url(scm_type, url, username=True, password=True, diff --git a/awx/main/utils/licensing.py b/awx/main/utils/licensing.py new file mode 100644 index 0000000000..d838e37e69 --- /dev/null +++ b/awx/main/utils/licensing.py @@ -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 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..b75ae5552e 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 = {}; - if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") { - rhCreds.REDHAT_USERNAME = data.REDHAT_USERNAME; + const subscriptionCreds = {}; + if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") { + subscriptionCreds.SUBSCRIPTIONS_USERNAME = data.SUBSCRIPTIONS_USERNAME; } - if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { - rhCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD; + if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") { + subscriptionCreds.SUBSCRIPTIONS_PASSWORD = data.SUBSCRIPTIONS_PASSWORD; } - return rhCreds; + return subscriptionCreds; }).catch(() => { return {}; }); 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..2cae41e363 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,30 +62,30 @@ export default insights: true }; - $scope.rhCreds = {}; + $scope.subscriptionCreds = {}; - if (rhCreds.REDHAT_USERNAME && rhCreds.REDHAT_USERNAME !== "") { - $scope.rhCreds.username = rhCreds.REDHAT_USERNAME; + if (subscriptionCreds.SUBSCRIPTIONS_USERNAME && subscriptionCreds.SUBSCRIPTIONS_USERNAME !== "") { + $scope.subscriptionCreds.username = subscriptionCreds.SUBSCRIPTIONS_USERNAME; } - if (rhCreds.REDHAT_PASSWORD && rhCreds.REDHAT_PASSWORD !== "") { - $scope.rhCreds.password = rhCreds.REDHAT_PASSWORD; + if (subscriptionCreds.SUBSCRIPTIONS_PASSWORD && subscriptionCreds.SUBSCRIPTIONS_PASSWORD !== "") { + $scope.subscriptionCreds.password = subscriptionCreds.SUBSCRIPTIONS_PASSWORD; $scope.showPlaceholderPassword = true; } }; - const updateRHCreds = (config) => { + const updateSubscriptionCreds = (config) => { Rest.setUrl(`${GetBasePath('settings')}system/`); Rest.get() .then(({data}) => { initVars(config); - if (data.REDHAT_USERNAME && data.REDHAT_USERNAME !== "") { - $scope.rhCreds.username = data.REDHAT_USERNAME; + if (data.SUBSCRIPTIONS_USERNAME && data.SUBSCRIPTIONS_USERNAME !== "") { + $scope.subscriptionCreds.username = data.SUBSCRIPTIONS_USERNAME; } - if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { - $scope.rhCreds.password = data.REDHAT_PASSWORD; + if (data.SUBSCRIPTIONS_PASSWORD && data.SUBSCRIPTIONS_PASSWORD !== "") { + $scope.subscriptionCreds.password = data.SUBSCRIPTIONS_PASSWORD; $scope.showPlaceholderPassword = true; } }).catch(() => { @@ -100,28 +100,23 @@ export default $scope.fileName = event.target.files[0].name; // Grab the key from the raw license file const raw = new FileReader(); - // readAsFoo runs async + raw.onload = function() { - try { - $scope.newLicense.file = JSON.parse(raw.result); - } catch(err) { - ProcessErrors($rootScope, null, null, null, - {msg: i18n._('Invalid file format. Please upload valid JSON.')}); - } + $scope.newLicense.manifest = btoa(raw.result); }; try { - raw.readAsText(event.target.files[0]); + raw.readAsBinaryString(event.target.files[0]); } catch(err) { ProcessErrors($rootScope, null, null, null, - {msg: i18n._('Invalid file format. Please upload valid JSON.')}); + {msg: i18n._('Invalid file format. Please upload a valid Red Hat Subscription Manifest.')}); } }; // HTML5 spec doesn't provide a way to customize file input css // So we hide the default input, show our own, and simulate clicks to the hidden input $scope.fakeClick = function() { - if($scope.user_is_superuser && (!$scope.rhCreds.username || $scope.rhCreds.username === '') && (!$scope.rhCreds.password || $scope.rhCreds.password === '')) { + if($scope.user_is_superuser && (!$scope.subscriptionCreds.username || $scope.subscriptionCreds.username === '') && (!$scope.subscriptionCreds.password || $scope.subscriptionCreds.password === '')) { $('#License-file').click(); } }; @@ -131,9 +126,9 @@ export default }; $scope.replacePassword = () => { - if ($scope.user_is_superuser && !$scope.newLicense.file) { + if ($scope.user_is_superuser && !$scope.newLicense.manifest) { $scope.showPlaceholderPassword = false; - $scope.rhCreds.password = ""; + $scope.subscriptionCreds.password = ""; $timeout(() => { $('.tooltip').remove(); $('#rh-password').focus(); @@ -142,9 +137,9 @@ export default }; $scope.lookupLicenses = () => { - if ($scope.rhCreds.username && $scope.rhCreds.password) { + if ($scope.subscriptionCreds.username && $scope.subscriptionCreds.password) { Wait('start'); - ConfigService.getSubscriptions($scope.rhCreds.username, $scope.rhCreds.password) + ConfigService.getSubscriptions($scope.subscriptionCreds.username, $scope.subscriptionCreds.password) .then(({data}) => { Wait('stop'); if (data && data.length > 0) { @@ -172,29 +167,30 @@ export default $scope.confirmLicenseSelection = () => { $scope.showLicenseModal = false; $scope.selectedLicense.fullLicense = $scope.rhLicenses.find((license) => { - return license.license_key === $scope.selectedLicense.modalKey; + return license.pool_id === $scope.selectedLicense.modalPoolId; }); - $scope.selectedLicense.modalKey = undefined; + $scope.selectedLicense.modalPoolId = undefined; }; $scope.cancelLicenseLookup = () => { $scope.showLicenseModal = false; - $scope.selectedLicense.modalKey = undefined; + $scope.selectedLicense.modalPoolId = undefined; }; $scope.submit = function() { Wait('start'); let payload = {}; - if ($scope.newLicense.file) { - payload = $scope.newLicense.file; + let attach = false; + if ($scope.newLicense.manifest) { + payload.manifest = $scope.newLicense.manifest; } else if ($scope.selectedLicense.fullLicense) { - payload = $scope.selectedLicense.fullLicense; + payload.pool_id = $scope.selectedLicense.fullLicense.pool_id; + attach = true; } - CheckLicense.post(payload, $scope.newLicense.eula) - .then((licenseInfo) => { + CheckLicense.post(payload, $scope.newLicense.eula, attach) + .finally((licenseInfo) => { reset(); - ConfigService.delete(); ConfigService.getConfig(licenseInfo) .then(function(config) { @@ -217,7 +213,7 @@ export default licenseMissing: false }); } else { - updateRHCreds(config); + updateSubscriptionCreds(config); $scope.success = true; $rootScope.licenseMissing = false; // for animation purposes diff --git a/awx/ui/client/src/license/license.partial.html b/awx/ui/client/src/license/license.partial.html index 2dc7beeb9c..037ac17256 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 + Compliant + Out of Compliance
@@ -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
@@ -64,53 +58,66 @@ {{license.license_info.current_instances}}
-
+
+
Hosts Remaining
+
+ {{license.license_info.free_instances}} +
+
+
Hosts Remaining
{{license.license_info.free_instances}}
-
If you are ready to upgrade, please contact us by clicking the button below
- +
If you are ready to upgrade or renew, please contact us by clicking the button below.
+
{{title}}
-
Welcome to Ansible Tower! Please complete the steps below to acquire a license.
+
Welcome to Red Hat Ansible Automation Platform! Please complete the steps below to activate your subscription.
+
+ + 1 + + + If you do not have a subscription, you can visit Red Hat to obtain a trial subscription. + +
+ +
+
+ + 2 + + + Select your Ansible Automation Platform subscription to use. + +
+
-
- - 1 - - - Please click the button below to visit Ansible's website to get a Tower license key. - -
- - -
- - 2 - - Choose your license file, agree to the End User License Agreement, and click submit. + Upload a Red Hat Subscription Manifest containing your subscription. To generate your subscription manifest, go to subscription allocations on the Red Hat Customer Portal.
* - License + Red Hat Subscription Manifest + + +
-
Upload a license file
- Browse + Browse {{fileName|translate}}
@@ -125,12 +132,12 @@
- 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. + 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.
- +
@@ -143,11 +150,11 @@
- +
- GET LICENSES + GET SUBSCRIPTIONS
@@ -158,6 +165,14 @@
+
+ + 3 + + + Agree to the End User License Agreement, and click submit. + +
* End User License Agreement @@ -200,7 +215,7 @@ Save successful!
- +
@@ -223,12 +238,12 @@