anaconda/anaconda-40.22.3.13/pyanaconda/modules/subscription/subscription.py
2024-11-14 21:39:56 -08:00

740 lines
30 KiB
Python

#
# 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