# # Kickstart module for subscription handling. # # Copyright (C) 2020 Red Hat, Inc. # # This copyrighted material is made available to anyone wishing to use, # modify, copy, or redistribute it subject to the terms and conditions of # the GNU General Public License v.2, or (at your option) any later version. # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY expressed or implied, including the implied warranties of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program; if not, write to the # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. Any Red Hat trademarks that are incorporated in the # source code or documentation are not subject to the GNU General Public # License and may only be used or replicated with the express permission of # Red Hat, Inc. # import copy import warnings from dasbus.typing import get_native from pyanaconda.core.payload import ProxyString, ProxyStringError from pyanaconda.core.signal import Signal from pyanaconda.core.constants import SECRET_TYPE_HIDDEN, SUBSCRIPTION_REQUEST_TYPE_ORG_KEY from pyanaconda.core.configuration.anaconda import conf from pyanaconda.modules.common.base import KickstartService from pyanaconda.modules.common.structures.subscription import SystemPurposeData, \ SubscriptionRequest from pyanaconda.modules.common.structures.secret import get_public_copy from pyanaconda.core.dbus import DBus from pyanaconda.modules.common.constants.services import SUBSCRIPTION from pyanaconda.modules.common.constants.objects import RHSM_CONFIG, RHSM_REGISTER_SERVER, \ RHSM_UNREGISTER, RHSM_ATTACH, RHSM_ENTITLEMENT, RHSM_SYSPURPOSE from pyanaconda.modules.common.containers import TaskContainer from pyanaconda.modules.common.structures.requirement import Requirement from pyanaconda.modules.subscription import system_purpose from pyanaconda.modules.subscription.kickstart import SubscriptionKickstartSpecification from pyanaconda.modules.subscription.subscription_interface import SubscriptionInterface from pyanaconda.modules.subscription.installation import ConnectToInsightsTask, \ RestoreRHSMDefaultsTask, TransferSubscriptionTokensTask from pyanaconda.modules.subscription.initialization import StartRHSMTask from pyanaconda.modules.subscription.runtime import SetRHSMConfigurationTask, \ RegisterWithUsernamePasswordTask, RegisterWithOrganizationKeyTask, \ UnregisterTask, AttachSubscriptionTask, SystemPurposeConfigurationTask, \ ParseAttachedSubscriptionsTask from pyanaconda.modules.subscription.rhsm_observer import RHSMObserver from pykickstart.errors import KickstartParseWarning from pyanaconda.anaconda_loggers import get_module_logger log = get_module_logger(__name__) class SubscriptionService(KickstartService): """The Subscription service.""" def __init__(self): super().__init__() # system purpose self._valid_roles = [] self._valid_slas = [] self._valid_usage_types = [] self._system_purpose_data = SystemPurposeData() self.system_purpose_data_changed = Signal() self._load_valid_system_purpose_values() # subscription request self._subscription_request = SubscriptionRequest() self.subscription_request_changed = Signal() # attached subscriptions self._attached_subscriptions = [] self.attached_subscriptions_changed = Signal() # Insights # What are the defaults for Red Hat Insights ? # - during a kickstart installation, the user # needs to opt-in by using the rhsm command # with the --connect-to-insights option # - during a GUI interactive installation the # "connect to Insights" checkbox is checked by default, # making Insights opt-out # - in both cases the system also needs to be subscribed, # or else the system can't be connected to Insights self._connect_to_insights = False self.connect_to_insights_changed = Signal() # registration status self.registered_changed = Signal() self._registered = False # subscription status self.subscription_attached_changed = Signal() self._subscription_attached = False # RHSM service startup and access self._rhsm_startup_task = StartRHSMTask(verify_ssl=conf.payload.verify_ssl) self._rhsm_observer = RHSMObserver(self._rhsm_startup_task.is_service_available) # RHSM config default values cache self._rhsm_config_defaults = None def publish(self): """Publish the module.""" TaskContainer.set_namespace(SUBSCRIPTION.namespace) DBus.publish_object(SUBSCRIPTION.object_path, SubscriptionInterface(self)) DBus.register_service(SUBSCRIPTION.service_name) def run(self): """Initiate RHSM service startup before starting the main loop. This way RHSM service can startup in parallel without blocking startup of the Subscription module. """ self._rhsm_startup_task.start() super().run() @property def kickstart_specification(self): """Return the kickstart specification.""" return SubscriptionKickstartSpecification def process_kickstart(self, data): """Process the kickstart data.""" log.debug("Processing kickstart data...") # system purpose # # Try if any of the values in kickstart match a valid field. # If it does, write the valid field value instead of the value from kickstart. # # This way a value in kickstart that has a different case and/or trailing white space # can still be used to preselect a value in a UI instead of being marked as a custom # user specified value. system_purpose_data = SystemPurposeData() system_purpose_data.role = system_purpose.process_field( data.syspurpose.role, self.valid_roles, "role" ) system_purpose_data.sla = system_purpose.process_field( data.syspurpose.sla, self.valid_slas, "sla" ) system_purpose_data.usage = system_purpose.process_field( data.syspurpose.usage, self.valid_usage_types, "usage" ) if data.syspurpose.addons: # As we do not have a list of valid addons available, we just use what was provided # by the user in kickstart verbatim. system_purpose_data.addons = data.syspurpose.addons self.set_system_purpose_data(system_purpose_data) # apply system purpose data, if any, so that it is all in place when we start # talking to the RHSM service if self.system_purpose_data.check_data_available(): self._apply_syspurpose() # subscription request subscription_request = SubscriptionRequest() # credentials if data.rhsm.organization: subscription_request.organization = data.rhsm.organization if data.rhsm.activation_keys: subscription_request.activation_keys.set_secret(data.rhsm.activation_keys) # if org id and at least one activation key is set, switch authentication # type to ORG & KEY if data.rhsm.organization and data.rhsm.activation_keys: subscription_request.type = SUBSCRIPTION_REQUEST_TYPE_ORG_KEY # custom URLs if data.rhsm.server_hostname: subscription_request.server_hostname = data.rhsm.server_hostname if data.rhsm.rhsm_baseurl: subscription_request.rhsm_baseurl = data.rhsm.rhsm_baseurl # HTTP proxy if data.rhsm.proxy: # first try to parse the proxy string from kickstart try: proxy = ProxyString(data.rhsm.proxy) if proxy.host: # ensure port is an integer and set to -1 if unknown port = int(proxy.port) if proxy.port else -1 subscription_request.server_proxy_hostname = proxy.host subscription_request.server_proxy_port = port # ensure no username translates to the expected "" # instead of the None returned by the ProxyString class subscription_request.server_proxy_user = proxy.username or "" subscription_request.server_proxy_password.set_secret(proxy.password) except ProxyStringError as e: # should not be fatal, but definitely logged as error message = "Failed to parse proxy for the rhsm command: {}".format(str(e)) warnings.warn(message, KickstartParseWarning) # set the resulting subscription request self.set_subscription_request(subscription_request) # insights self.set_connect_to_insights(bool(data.rhsm.connect_to_insights)) def setup_kickstart(self, data): """Return the kickstart string. NOTE: We are not writing out the rhsm command as the input can contain sensitive data (activation keys, proxy passwords) that we would have to omit from the output kickstart. This in turn would make the rhsm command incomplete & would turn the output kickstart invalid as a result. For this reason we skip the rhsm command completely in the output kickstart. """ # system purpose data.syspurpose.role = self.system_purpose_data.role data.syspurpose.sla = self.system_purpose_data.sla data.syspurpose.usage = self.system_purpose_data.usage data.syspurpose.addons = self.system_purpose_data.addons # system purpose configuration def _load_valid_system_purpose_values(self): """Load lists of valid roles, SLAs and usage types. About role/sla/validity: - an older installation image might have older list of valid fields, missing fields that have become valid after the image has been released - fields that have been valid in the past might be dropped in the future - there is no list of valid addons Due to this we need to take into account that the listing might not always be comprehensive and that we need to allow what might on a first glance look like invalid values to be written to the target system. """ roles, slas, usage_types = system_purpose.get_valid_fields() self._valid_roles = roles self._valid_slas = slas self._valid_usage_types = usage_types @property def valid_roles(self): """Return a list of valid roles. :return: list of valid roles :rtype: list of strings """ return self._valid_roles @property def valid_slas(self): """Return a list of valid SLAs. :return: list of valid SLAs :rtype: list of strings """ return self._valid_slas @property def valid_usage_types(self): """Return a list of valid usage types. :return: list of valid usage types :rtype: list of strings """ return self._valid_usage_types @property def system_purpose_data(self): """System purpose data. A DBus structure holding information about system purpose, such as role, sla, usage and addons. :return: system purpose DBus structure :rtype: DBusData instance """ return self._system_purpose_data def set_system_purpose_data(self, system_purpose_data): """Set system purpose data. Set the complete DBus structure containing system purpose data. :param system_purpose_data: system purpose data structure to be set :type system_purpose_data: DBus structure """ self._system_purpose_data = system_purpose_data self.system_purpose_data_changed.emit() log.debug("System purpose data set to %s.", system_purpose_data) def _apply_syspurpose(self): """Apply system purpose information to the installation environment.""" log.debug("subscription: Applying system purpose data") task = self.set_system_purpose_with_task() task.run() def set_system_purpose_with_task(self): """Set system purpose for the installed system with an installation task. :return: a DBus path of an installation task """ rhsm_syspurpose_proxy = self.rhsm_observer.get_proxy(RHSM_SYSPURPOSE) task = SystemPurposeConfigurationTask( rhsm_syspurpose_proxy=rhsm_syspurpose_proxy, system_purpose_data=self.system_purpose_data ) return task # subscription request @property def subscription_request(self): """Subscription request. A DBus structure holding data to be used to subscribe the system. :return: subscription request DBus structure :rtype: DBusData instance """ # Return a deep copy of the subscription request that # has also been cleared of private data. # Thankfully the secret Dbus structures modules # has the get_public_copy() method that does just # that. It creates a deep copy & clears # all SecretData and SecretDataList instances. return get_public_copy(self._subscription_request) def set_subscription_request(self, subscription_request): """Set a subscription request. Set the complete DBus structure containing subscription request data. :param subscription_request: subscription request structure to be set :type subscription_request: DBus structure """ self._replace_current_subscription_request(subscription_request) self.subscription_request_changed.emit() log.debug("A subscription request set: %s", str(self._subscription_request)) @property def attached_subscriptions(self): """A list of attached subscriptions. The list holds DBus structures with each structure holding information about one attached subscription. A system that has been successfully registered and subscribed usually has one or more subscriptions attached. :return: list of DBus structures, one per attached subscription :rtype: list of AttachedSubscription instances """ return self._attached_subscriptions def set_attached_subscriptions(self, attached_subscriptions): """Set the list of attached subscriptions. :param attached_subscriptions: list of attached subscriptions to be set :type attached_subscriptions: list of AttachedSubscription instances """ self._attached_subscriptions = attached_subscriptions self.attached_subscriptions_changed.emit() # as there is no public setter in the DBus API, we need to emit # the properties changed signal here manually self.module_properties_changed.emit() log.debug("Attached subscriptions set: %s", str(self._attached_subscriptions)) def _replace_current_subscription_request(self, new_request): """Replace current subscription request without loosing sensitive data. We need to do this to prevent blank SecretData & SecretDataList instances from wiping out previously set secret data. The instances will be blank every time a SubscriptionRequest that went through get_public_copy() comes back with the secret data fields unchanged. So what we do is depends on type of the incoming secret data: - SECRET_TYPE_NONE - use structure from new request unchanged, clearing previously set data (if any) - SECRET_TYPE_HIDDEN - secret data has been set previously and cleared when SubscriptionRequest was sent out; put secret data from current request to the new one to prevent it from being lost (this will also switch the secret data instance to SECRET_TYPE_TEXT so that the Subscription module can read it internally) - SECRET_TYPE_TEXT - this is new secret entry, we can keep it as is """ current_request = self._subscription_request # Red Hat account password if new_request.account_password.type == SECRET_TYPE_HIDDEN: new_request.account_password = copy.deepcopy( current_request.account_password) # activation keys used together with an organization id if new_request.activation_keys.type == SECRET_TYPE_HIDDEN: new_request.activation_keys = copy.deepcopy( current_request.activation_keys) # RHSM HTTP proxy password if new_request.server_proxy_password.type == SECRET_TYPE_HIDDEN: new_request.server_proxy_password = copy.deepcopy( current_request.server_proxy_password) # replace current request self._subscription_request = new_request @property def connect_to_insights(self): """Indicates if the target system should be connected to Red Hat Insights. :return: True to connect, False not to connect the target system to Insights :rtype: bool """ return self._connect_to_insights def set_connect_to_insights(self, connect): """Set if the target system should be connected to Red Hat Insights. :param bool connect: set to True to connect, set to False not to connect """ self._connect_to_insights = connect self.connect_to_insights_changed.emit() log.debug("Connect target system to Insights set to: %s", self._connect_to_insights) # registration status @property def registered(self): """Return True if the system has been registered. NOTE: Together with the subscription_attached property the registered property can be used to detect that the system is registered but has not subscription attached. This is generally a sign something went wrong, usually when trying to attach subscription. :return: True if the system has been registered, False otherwise :rtype: bool """ return self._registered def set_registered(self, system_registered): """Set if the system is registered. :param bool system_registered: True if system has been registered, False otherwise """ self._registered = system_registered self.registered_changed.emit() # as there is no public setter in the DBus API, we need to emit # the properties changed signal here manually self.module_properties_changed.emit() log.debug("System registered set to: %s", system_registered) # subscription status @property def subscription_attached(self): """Return True if a subscription has been attached to the system. :return: True if a subscription has been attached to the system, False otherwise :rtype: bool """ return self._subscription_attached def set_subscription_attached(self, system_subscription_attached): """Set a subscription has been attached to the system. :param bool system_registered: True if subscription has been attached, False otherwise """ self._subscription_attached = system_subscription_attached self.subscription_attached_changed.emit() # as there is no public setter in the DBus API, we need to emit # the properties changed signal here manually self.module_properties_changed.emit() log.debug("Subscription attached set to: %s", system_subscription_attached) # tasks def install_with_tasks(self): """Return the installation tasks of this module. Order of execution is important: - before transferring subscription tokens we need to restore the INFO log level in rhsm.conf or else target system will end up with RHSM logging in DEBUG mode - transfer subscription tokens - connect to insights, this can run only once subscription tokens are in place on the target system or else it would fail as Insights client needs the subscription tokens to authenticate to the Red Hat Insights online service :returns: list of installation tasks """ return [ RestoreRHSMDefaultsTask( rhsm_config_proxy=self.rhsm_observer.get_proxy(RHSM_CONFIG) ), TransferSubscriptionTokensTask( sysroot=conf.target.system_root, transfer_subscription_tokens=self.subscription_attached ), ConnectToInsightsTask( sysroot=conf.target.system_root, subscription_attached=self.subscription_attached, connect_to_insights=self.connect_to_insights ) ] # RHSM DBus API access @property def rhsm_observer(self): """Provide access to the RHSM DBus service observer. This observer handles various peculiarities of the RHSM DBus API startup and should be used as the only access point to the RHSM Dbus API. If you need to RHSM DBus API object, just call the get_proxy() method of the observer with object identifier. :return: RHSM DBus API observer :rtype: RHSMObserver instance """ return self._rhsm_observer def _flatten_rhsm_nested_dict(self, nested_dict): """Convert the GetAll() returned nested dict into a flat one. RHSM returns a nested dict with categories on top and category keys & values inside. This is not convenient for setting keys based on original values, so let's normalize the dict to the flat key based structure similar to what's used by SetAll(). :param dict nested_dict: the nested dict returned by GetAll() :return: flat key/value dictionary, similar to format used by SetAll() :rtype: dict """ flat_dict = {} for category_key, category_dict in nested_dict.items(): for key, value in category_dict.items(): flat_key = "{}.{}".format(category_key, key) flat_dict[flat_key] = value return flat_dict def get_rhsm_config_defaults(self): """Return RHSM config default values. We need to have these available in case the user decides to return to default values from a custom value at runtime. This method is lazy evaluated, the first call it fetches the full config dict from RHSM and subsequent calls are then served from cache. Due to this it is important not to set RHSM configuration values before first calling this method to populate the cache or else the method might return non-default (Anaconda overwritten) data. NOTE: While RHSM GetAll() DBus call returns a nested dictionary, we turn it into a flat key/value dict, in the same format SetAll() uses. :return : dictionary of default RHSM configuration values :rtype: dict """ if self._rhsm_config_defaults is None: # config defaults cache not yet populated, do it now proxy = self.rhsm_observer.get_proxy(RHSM_CONFIG) # turn the variant into a dict with get_native() nested_dict = get_native(proxy.GetAll("")) # flatten the nested dict flat_dict = self._flatten_rhsm_nested_dict(nested_dict) self._rhsm_config_defaults = flat_dict return self._rhsm_config_defaults def set_rhsm_config_with_task(self): """Set RHSM config values based on current subscription request. :return: a DBus path of an installation task """ # NOTE: we access self._subscription_request directly # to avoid the sensitive data clearing happening # in the subscription_request property getter rhsm_config_proxy = self.rhsm_observer.get_proxy(RHSM_CONFIG) task = SetRHSMConfigurationTask(rhsm_config_proxy=rhsm_config_proxy, rhsm_config_defaults=self.get_rhsm_config_defaults(), subscription_request=self._subscription_request) return task def register_username_password_with_task(self): """Register with username and password based on current subscription request. :return: a DBus path of an installation task """ # NOTE: we access self._subscription_request directly # to avoid the sensitive data clearing happening # in the subscription_request property getter username = self._subscription_request.account_username password = self._subscription_request.account_password.value register_server_proxy = self.rhsm_observer.get_proxy(RHSM_REGISTER_SERVER) task = RegisterWithUsernamePasswordTask(rhsm_register_server_proxy=register_server_proxy, username=username, password=password) # if the task succeeds, it means the system has been registered task.succeeded_signal.connect( lambda: self.set_registered(True)) return task def register_organization_key_with_task(self): """Register with organization and activation key(s) based on current subscription request. :return: a DBus path of an installation task """ # NOTE: we access self._subscription_request directly # to avoid the sensitive data clearing happening # in the subscription_request property getter organization = self._subscription_request.organization activation_keys = self._subscription_request.activation_keys.value register_server_proxy = self.rhsm_observer.get_proxy(RHSM_REGISTER_SERVER) task = RegisterWithOrganizationKeyTask(rhsm_register_server_proxy=register_server_proxy, organization=organization, activation_keys=activation_keys) # if the task succeeds, it means the system has been registered task.succeeded_signal.connect( lambda: self.set_registered(True)) return task def unregister_with_task(self): """Unregister the system. :return: a DBus path of an installation task """ rhsm_unregister_proxy = self.rhsm_observer.get_proxy(RHSM_UNREGISTER) task = UnregisterTask(rhsm_unregister_proxy=rhsm_unregister_proxy) # we will no longer be registered and subscribed if the task is successful, # so set the corresponding properties appropriately task.succeeded_signal.connect( lambda: self.set_registered(False)) task.succeeded_signal.connect( lambda: self.set_subscription_attached(False)) # and clear attached subscriptions task.succeeded_signal.connect( lambda: self.set_attached_subscriptions([])) return task def attach_subscription_with_task(self): """Attach a subscription. This should only be run on a system that has been successfully registered. Attached subscription depends on system type, system purpose data and entitlements available for the account that has been used for registration. :return: a DBus path of an installation task """ sla = self.system_purpose_data.sla rhsm_attach_proxy = self.rhsm_observer.get_proxy(RHSM_ATTACH) task = AttachSubscriptionTask(rhsm_attach_proxy=rhsm_attach_proxy, sla=sla) # if the task succeeds, it means a subscription has been attached task.succeeded_signal.connect( lambda: self.set_subscription_attached(True)) return task def _set_system_subscription_data(self, system_subscription_data): """A helper method invoked in ParseAttachedSubscritionsTask completed signal. :param system_subscription_data: a named tuple holding attached subscriptions and final system purpose data """ self.set_attached_subscriptions(system_subscription_data.attached_subscriptions) self.set_system_purpose_data(system_subscription_data.system_purpose_data) def parse_attached_subscriptions_with_task(self): """Parse attached subscriptions with task. Parse data about attached subscriptions and final system purpose data. This data is available as JSON strings via the RHSM DBus API. :return: a DBus path of an installation task """ rhsm_entitlement_proxy = self.rhsm_observer.get_proxy(RHSM_ENTITLEMENT) rhsm_syspurpose_proxy = self.rhsm_observer.get_proxy(RHSM_SYSPURPOSE) task = ParseAttachedSubscriptionsTask(rhsm_entitlement_proxy=rhsm_entitlement_proxy, rhsm_syspurpose_proxy=rhsm_syspurpose_proxy) # if the task succeeds, set attached subscriptions and system purpose data task.succeeded_signal.connect( lambda: self._set_system_subscription_data(task.get_result()) ) return task def collect_requirements(self): """Return installation requirements for this module. :return: a list of requirements """ requirements = [] # check if we need the insights-client package, which is needed to connect the # target system to Red Hat Insights if self.subscription_attached and self.connect_to_insights: # establishing a connection to Red Hat Insights has been requested # and we need the insights-client package to be present in the # target system chroot for that requirements.append( Requirement.for_package( "insights-client", reason="Needed to connect the target system to Red Hat Insights." ) ) return requirements