440 lines
16 KiB
Python
440 lines
16 KiB
Python
# 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, timezone
|
|
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)
|
|
subs = []
|
|
for f in z.filelist:
|
|
if f.filename.startswith('export/entitlements') and f.filename.endswith('.json'):
|
|
subs.append(json.loads(z.open(f).read()))
|
|
if subs:
|
|
return subs
|
|
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):
|
|
def is_appropriate_manifest_sub(sub):
|
|
if sub['pool']['activeSubscription'] is False:
|
|
return False
|
|
now = datetime.now(timezone.utc)
|
|
if parse_date(sub['startDate']) > now:
|
|
return False
|
|
if parse_date(sub['endDate']) < now:
|
|
return False
|
|
products = sub['pool']['providedProducts']
|
|
if any(product.get('productId') == '480' for product in products):
|
|
return True
|
|
return False
|
|
|
|
def _can_aggregate(sub, license):
|
|
# We aggregate multiple subs into a larger meta-sub, if they match
|
|
#
|
|
# No current sub in aggregate
|
|
if not license:
|
|
return True
|
|
# Same SKU type (SER vs MCT vs others)?
|
|
if license['sku'][0:3] != sub['pool']['productId'][0:3]:
|
|
return False
|
|
return True
|
|
|
|
# Parse output for subscription metadata to build config
|
|
license = dict()
|
|
for sub in manifest:
|
|
if not is_appropriate_manifest_sub(sub):
|
|
logger.warning("Subscription %s (%s) in manifest is not active or for another product" %
|
|
(sub['pool']['productName'], sub['pool']['productId']))
|
|
continue
|
|
if not _can_aggregate(sub, license):
|
|
logger.warning("Subscription %s (%s) in manifest does not match other manifest subscriptions" %
|
|
(sub['pool']['productName'], sub['pool']['productId']))
|
|
continue
|
|
|
|
license.setdefault('sku', sub['pool']['productId'])
|
|
license.setdefault('subscription_name', sub['pool']['productName'])
|
|
license.setdefault('pool_id', sub['pool']['id'])
|
|
license.setdefault('product_name', sub['pool']['productName'])
|
|
license.setdefault('valid_key', True)
|
|
license.setdefault('license_type', 'enterprise')
|
|
license.setdefault('satellite', False)
|
|
# Use the nearest end date
|
|
endDate = parse_date(sub['endDate'])
|
|
currentEndDateStr = license.get('license_date', '4102462800') # 2100-01-01
|
|
currentEndDate = datetime.fromtimestamp(int(currentEndDateStr), timezone.utc)
|
|
if endDate < currentEndDate:
|
|
license['license_date'] = endDate.strftime('%s')
|
|
instances = sub['quantity']
|
|
license['instance_count'] = license.get('instance_count', 0) + instances
|
|
license['subscription_name'] = re.sub(r'[\d]* Managed Nodes', '%d Managed Nodes' % license['instance_count'], license['subscription_name'])
|
|
|
|
if not license:
|
|
logger.error("No valid subscriptions found in manifest")
|
|
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):
|
|
port = None
|
|
try:
|
|
verify = str(self.config.get("rhsm", "repo_ca_cert"))
|
|
port = str(self.config.get("server", "port"))
|
|
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)
|
|
if port:
|
|
host = ':'.join([host, port])
|
|
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(product.get('productId') == '480' for product in 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
|
|
instance_count = int(attrs.get('instance_count', 0))
|
|
attrs['current_instances'] = current_instances
|
|
free_instances = (instance_count - 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
|