diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index ede960ecb6..ab7d61fd23 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -14,6 +14,7 @@ from awx.api.views import ( ApiV2RootView, ApiV2PingView, ApiV2ConfigView, + ApiV2SubscriptionView, AuthView, UserMeList, DashboardView, @@ -94,6 +95,7 @@ v2_urls = [ url(r'^metrics/$', MetricsView.as_view(), name='metrics_view'), url(r'^ping/$', ApiV2PingView.as_view(), name='api_v2_ping_view'), url(r'^config/$', ApiV2ConfigView.as_view(), name='api_v2_config_view'), + url(r'^config/subscriptions/$', ApiV2SubscriptionView.as_view(), name='api_v2_subscription_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 a6d92e9578..9302249e67 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -147,6 +147,7 @@ from awx.api.views.root import ( # noqa ApiV2RootView, ApiV2PingView, ApiV2ConfigView, + ApiV2SubscriptionView, ) diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 922041c8b5..91f6b62149 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -17,6 +17,8 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework import status +import requests + from awx.api.generics import APIView from awx.main.ha import is_ha_environment from awx.main.utils import ( @@ -169,6 +171,45 @@ class ApiV2PingView(APIView): return Response(response) +class ApiV2SubscriptionView(APIView): + + permission_classes = (IsAuthenticated,) + name = _('Configuration') + swagger_topic = 'System Configuration' + + def check_permissions(self, request): + super(ApiV2SubscriptionView, 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): + from awx.main.utils.common import get_licenser + data = request.data.copy() + if data.get('rh_password') == '$encrypted$': + data['rh_password'] = settings.REDHAT_PASSWORD + try: + user, pw = data.get('rh_username'), data.get('rh_password') + validated = get_licenser().validate_rh(user, pw) + if user: + settings.REDHAT_USERNAME = data['rh_username'] + if pw: + settings.REDHAT_PASSWORD = data['rh_password'] + 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).") + if isinstance(exc, (ValueError, OSError)) and exc.args: + msg = exc.args[0] + logger.exception(smart_text(u"Invalid license submitted."), + extra=dict(actor=request.user.username)) + return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST) + + return Response(validated) + + class ApiV2ConfigView(APIView): permission_classes = (IsAuthenticated,) diff --git a/awx/main/access.py b/awx/main/access.py index 1a87eff38a..e9957656de 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -317,10 +317,19 @@ class BaseAccess(object): validation_info['time_remaining'] = 99999999 validation_info['grace_period_remaining'] = 99999999 + report_violation = lambda message: logger.error(message) + + if ( + validation_info.get('trial', False) is True or + validation_info['instance_count'] == 10 # basic 10 license + ): + def report_violation(message): + raise PermissionDenied(message) + if check_expiration and validation_info.get('time_remaining', None) is None: raise PermissionDenied(_("License is missing.")) - if check_expiration and validation_info.get("grace_period_remaining") <= 0: - raise PermissionDenied(_("License has expired.")) + elif check_expiration and validation_info.get("grace_period_remaining") <= 0: + report_violation(_("License has expired.")) free_instances = validation_info.get('free_instances', 0) available_instances = validation_info.get('available_instances', 0) @@ -328,11 +337,11 @@ class BaseAccess(object): if add_host_name: host_exists = Host.objects.filter(name=add_host_name).exists() if not host_exists and free_instances == 0: - raise PermissionDenied(_("License count of %s instances has been reached.") % available_instances) + report_violation(_("License count of %s instances has been reached.") % available_instances) elif not host_exists and free_instances < 0: - raise PermissionDenied(_("License count of %s instances has been exceeded.") % available_instances) + report_violation(_("License count of %s instances has been exceeded.") % available_instances) elif not add_host_name and free_instances < 0: - raise PermissionDenied(_("Host count exceeds available instances.")) + report_violation(_("Host count exceeds available instances.")) def check_org_host_limit(self, data, add_host_name=None): validation_info = get_licenser().validate() diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 6ae7ba69d5..7a23c1a63f 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -919,7 +919,8 @@ class Command(BaseCommand): new_count = Host.objects.active_count() if time_remaining <= 0 and not license_info.get('demo', False): logger.error(LICENSE_EXPIRED_MESSAGE) - raise CommandError("License has expired!") + if license_info.get('trial', False) is True: + raise CommandError("License has expired!") # special check for tower-type inventory sources # but only if running the plugin TOWER_SOURCE_FILES = ['tower.yml', 'tower.yaml'] @@ -936,7 +937,11 @@ class Command(BaseCommand): logger.error(DEMO_LICENSE_MESSAGE % d) else: logger.error(LICENSE_MESSAGE % d) - raise CommandError('License count exceeded!') + if ( + license_info.get('trial', False) is True or + license_info['instance_count'] == 10 # basic 10 license + ): + raise CommandError('License count exceeded!') def check_org_host_limit(self): license_info = get_licenser().validate() diff --git a/awx/ui/client/legacy/styles/ansible-ui.less b/awx/ui/client/legacy/styles/ansible-ui.less index 708cc44496..a85621fd9b 100644 --- a/awx/ui/client/legacy/styles/ansible-ui.less +++ b/awx/ui/client/legacy/styles/ansible-ui.less @@ -1595,7 +1595,7 @@ tr td button i { padding: 20px 0; .alert { - padding: 10px; + padding: 0px; margin: 0; word-wrap: break-word; } 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 a03b4cbfa5..20ca06fd7f 100644 --- a/awx/ui/client/src/configuration/forms/settings-form.route.js +++ b/awx/ui/client/src/configuration/forms/settings-form.route.js @@ -53,4 +53,24 @@ export default { } }); }], + resolve: { + rhCreds: ['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; + } + + if (data.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { + rhCreds.REDHAT_PASSWORD = data.REDHAT_PASSWORD; + } + + return rhCreds; + }).catch(() => { + return {}; + }); + }] + } }; \ No newline at end of file diff --git a/awx/ui/client/src/license/checkLicense.factory.js b/awx/ui/client/src/license/checkLicense.factory.js index 04a658fa82..e76f1ea0b7 100644 --- a/awx/ui/client/src/license/checkLicense.factory.js +++ b/awx/ui/client/src/license/checkLicense.factory.js @@ -5,29 +5,29 @@ *************************************************/ export default - ['$state', '$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', - 'ConfigService', - function($state, $rootScope, Rest, GetBasePath, ProcessErrors, - ConfigService){ + ['$state', '$rootScope', 'Rest', 'GetBasePath', + 'ConfigService', '$q', + function($state, $rootScope, Rest, GetBasePath, + ConfigService, $q){ return { get: function() { var config = ConfigService.get(); return config.license_info; }, - post: function(license, eula){ + post: function(payload, eula){ var defaultUrl = GetBasePath('config'); Rest.setUrl(defaultUrl); - var data = license; + var data = payload; data.eula_accepted = eula; + return Rest.post(JSON.stringify(data)) .then((response) =>{ return response.data; }) - .catch(({res, status}) => { - ProcessErrors($rootScope, res, status, null, {hdr: 'Error!', - msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status}); - }); + .catch(({data}) => { + return $q.reject(data); + }); }, valid: function(license) { diff --git a/awx/ui/client/src/license/license.block.less b/awx/ui/client/src/license/license.block.less index 08a6c624a6..f86d323ab8 100644 --- a/awx/ui/client/src/license/license.block.less +++ b/awx/ui/client/src/license/license.block.less @@ -26,6 +26,21 @@ display: block; width: 100%; } +.License-file--left { + display: flex; + flex:1; + overflow: hidden; +} +.License-file--middle { + display: flex; + flex: 0 0 auto; + padding: 0px 20px; + flex-direction: column; +} +.License-file--right { + display: flex; + flex:1; +} .License-submit--success.ng-hide-add, .License-submit--success.ng-hide-remove { transition: all ease-in-out 0.5s; } @@ -109,10 +124,11 @@ } } -.License-submit--success{ +.License-submit--success, .License-submit--failure { margin: 0; } .License-file--container { + display: flex; input[type=file] { display: none; } @@ -148,3 +164,127 @@ padding: 10px 0; font-weight: bold; } + +.License-separator { + display: flex; + flex: 1; + background: linear-gradient(#d7d7d7, #d7d7d7) no-repeat center/2px 100%; +} + +.License-licenseStepHelp { + font-size: 12px; + font-style: italic; + margin-bottom: 10px; +} + +.License-filePicker { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.License-rhCredField { + margin-bottom: 10px; +} + +.License-label { + color: @field-label; + font-weight: 400; +} + +.License-action { + display: flex; + flex-direction: row; + align-content:flex-end; +} + +.License-actionError { + flex: 1; +} + +.License-subSelectorModal { + height: 100%; + width: 100%; + position: fixed; + top: 0; + left: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 1040; + display: flex; + align-items: center; + justify-content: center; +} + +.License-modal { + width: 750px; +} + +.License-modalBody { + border: 1px solid @b7grey; + max-height: 550px; + overflow: scroll; + border-radius: 4px; +} + +.License-modalRow { + display: flex; + padding: 10px; +} + +.License-modalRow:not(:last-of-type) { + border-bottom: 1px solid @b7grey; +} + +.License-modalRowRadio { + flex: 0 0 40px; + display: flex; + align-items: center; +} + +.License-trialTag { + font-weight: 100; + background-color: #ebebeb; + border-radius: 5px; + color: #606060; + font-size: 10px; + margin-right: 10px; + padding: 3px 9px; + line-height: 14px; + word-break: keep-all; + display: inline-flex; +} + +.License-introText { + margin-bottom: 10px; +} + +.License-getLicensesButton { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; +} + +.License-checkboxLabel { + margin-left: 5px; + font-weight: normal; +} + +.License-modalRowDetails { + flex: 1; +} + +.License-modalRowDetailsLabel { + font-weight: normal; + width: 100%; +} + +.License-modalRowDetailsRow { + margin-bottom: 10px; +} + +.License-modalRowDetails--50 { + display: flex; + flex-basis: 50%; + align-items: center; + line-height: 21px; +} diff --git a/awx/ui/client/src/license/license.controller.js b/awx/ui/client/src/license/license.controller.js index ac80686a4f..c0f9012edf 100644 --- a/awx/ui/client/src/license/license.controller.js +++ b/awx/ui/client/src/license/license.controller.js @@ -7,11 +7,10 @@ import {N_} from "../i18n"; export default - ['Wait', '$state', '$scope', '$rootScope', - 'ProcessErrors', 'CheckLicense', 'moment','$window', - 'ConfigService', 'pendoService', 'insightsEnablementService', 'i18n', 'config', - function(Wait, $state, $scope, $rootScope, ProcessErrors, CheckLicense, moment, - $window, ConfigService, pendoService, insightsEnablementService, i18n, config) { + ['Wait', '$state', '$scope', '$rootScope', 'ProcessErrors', 'CheckLicense', 'moment', '$timeout', 'Rest', + '$window', 'ConfigService', 'pendoService', 'insightsEnablementService', 'i18n', 'config', 'rhCreds', 'GetBasePath', + function(Wait, $state, $scope, $rootScope, ProcessErrors, CheckLicense, moment, $timeout, Rest, + $window, ConfigService, pendoService, insightsEnablementService, i18n, config, rhCreds, GetBasePath) { const calcDaysRemaining = function(seconds) { // calculate the number of days remaining on the license @@ -33,10 +32,12 @@ export default }; const reset = function() { - document.getElementById('License-form').reset(); + $scope.newLicense.eula = undefined; + $scope.rhCreds = {}; + $scope.selectedLicense = {}; }; - const init = function(config) { + const initVars = (config) => { // license/license.partial.html compares fileName $scope.fileName = N_("No file selected."); @@ -53,13 +54,44 @@ export default $scope.time.expiresOn = calcExpiresOn($scope.license.license_info.license_date); $scope.valid = CheckLicense.valid($scope.license.license_info); $scope.compliant = $scope.license.license_info.compliant; + $scope.selectedLicense = {}; $scope.newLicense = { pendo: true, insights: true }; + + $scope.rhCreds = {}; + + if (rhCreds.REDHAT_USERNAME && rhCreds.REDHAT_USERNAME !== "") { + $scope.rhCreds.username = rhCreds.REDHAT_USERNAME; + } + + if (rhCreds.REDHAT_PASSWORD && rhCreds.REDHAT_PASSWORD !== "") { + $scope.rhCreds.password = rhCreds.REDHAT_PASSWORD; + $scope.showPlaceholderPassword = true; + } }; - init(config); + const updateRHCreds = (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.REDHAT_PASSWORD && data.REDHAT_PASSWORD !== "") { + $scope.rhCreds.password = data.REDHAT_PASSWORD; + $scope.showPlaceholderPassword = true; + } + }).catch(() => { + initVars(config); + }); + }; + + initVars(config); $scope.getKey = function(event) { // Mimic HTML5 spec, show filename @@ -87,7 +119,7 @@ export default // 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) { + if($scope.user_is_superuser && (!$scope.rhCreds.username || $scope.rhCreds.username === '') && (!$scope.rhCreds.password || $scope.rhCreds.password === '')) { $('#License-file').click(); } }; @@ -96,44 +128,112 @@ export default $window.open('https://www.ansible.com/license', '_blank'); }; - $scope.submit = function() { - Wait('start'); - CheckLicense.post($scope.newLicense.file, $scope.newLicense.eula) - .then((licenseInfo) => { - reset(); + $scope.replacePassword = () => { + if ($scope.user_is_superuser && !$scope.newLicense.file) { + $scope.showPlaceholderPassword = false; + $scope.rhCreds.password = ""; + $timeout(() => { + $('.tooltip').remove(); + $('#rh-password').focus(); + }); + } + }; - ConfigService.delete(); - ConfigService.getConfig(licenseInfo) - .then(function(config) { - - if ($rootScope.licenseMissing === true) { - if ($scope.newLicense.pendo) { - pendoService.updatePendoTrackingState('detailed'); - pendoService.issuePendoIdentity(); - } else { - pendoService.updatePendoTrackingState('off'); - } - - if ($scope.newLicense.insights) { - insightsEnablementService.updateInsightsTrackingState(true); - } else { - insightsEnablementService.updateInsightsTrackingState(false); - } - - $state.go('dashboard', { - licenseMissing: false - }); - } else { - init(config); - $scope.success = true; - $rootScope.licenseMissing = false; - // for animation purposes - const successTimeout = setTimeout(function() { - $scope.success = false; - clearTimeout(successTimeout); - }, 4000); - } - }); + $scope.lookupLicenses = () => { + if ($scope.rhCreds.username && $scope.rhCreds.password) { + Wait('start'); + ConfigService.getSubscriptions($scope.rhCreds.username, $scope.rhCreds.password) + .then(({data}) => { + Wait('stop'); + if (data && data.length > 0) { + $scope.rhLicenses = data; + if ($scope.selectedLicense.fullLicense) { + $scope.selectedLicense.modalKey = $scope.selectedLicense.fullLicense.license_key; + } + $scope.showLicenseModal = true; + } else { + ProcessErrors($scope, data, status, null, { + hdr: i18n._('No Licenses Found'), + msg: i18n._('We were unable to locate licenses associated with this account') + }); + } + }) + .catch(({data, status}) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: i18n._('Error Fetching Licenses') }); - }; + }); + } + }; + + $scope.confirmLicenseSelection = () => { + $scope.showLicenseModal = false; + $scope.selectedLicense.fullLicense = $scope.rhLicenses.find((license) => { + return license.license_key === $scope.selectedLicense.modalKey; + }); + $scope.selectedLicense.modalKey = undefined; + }; + + $scope.cancelLicenseLookup = () => { + $scope.showLicenseModal = false; + $scope.selectedLicense.modalKey = undefined; + }; + + $scope.submit = function() { + Wait('start'); + let payload = {}; + if ($scope.newLicense.file) { + payload = $scope.newLicense.file; + } else if ($scope.selectedLicense.fullLicense) { + payload = $scope.selectedLicense.fullLicense; + if ($scope.rhCreds.username && $scope.rhCreds.password) { + payload.rh_password = $scope.rhCreds.password; + payload.rh_username = $scope.rhCreds.username; + } + } + + CheckLicense.post(payload, $scope.newLicense.eula) + .then((licenseInfo) => { + reset(); + + ConfigService.delete(); + ConfigService.getConfig(licenseInfo) + .then(function(config) { + + if ($rootScope.licenseMissing === true) { + if ($scope.newLicense.pendo) { + pendoService.updatePendoTrackingState('detailed'); + pendoService.issuePendoIdentity(); + } else { + pendoService.updatePendoTrackingState('off'); + } + + if ($scope.newLicense.insights) { + insightsEnablementService.updateInsightsTrackingState(true); + } else { + insightsEnablementService.updateInsightsTrackingState(false); + } + + $state.go('dashboard', { + licenseMissing: false + }); + } else { + updateRHCreds(config); + $scope.success = true; + $rootScope.licenseMissing = false; + // for animation purposes + const successTimeout = setTimeout(function() { + $scope.success = false; + clearTimeout(successTimeout); + }, 4000); + } + }); + }).catch(({data, status}) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: i18n._('Error Applying License') + }); + }); + }; }]; diff --git a/awx/ui/client/src/license/license.partial.html b/awx/ui/client/src/license/license.partial.html index 96b44b6bb9..924ec5c459 100644 --- a/awx/ui/client/src/license/license.partial.html +++ b/awx/ui/client/src/license/license.partial.html @@ -8,7 +8,7 @@