From b1b8940ccd3b1a11f710096ec43a75b2d5303432 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Sat, 19 Sep 2020 23:56:45 +0000 Subject: [PATCH] Add set-temp-url-secret action --- src/actions.yaml | 13 ++ src/actions/actions.py | 122 +++++++++++++++++ src/actions/set-temp-url-secret | 1 + src/config.yaml | 63 ++++++++- src/layer.yaml | 5 +- src/lib/charm/openstack/ironic/clients.py | 122 +++++++++++++++++ .../openstack/ironic/controller_utils.py | 8 ++ src/lib/charm/openstack/ironic/ironic.py | 125 ++++++++++++++++-- src/reactive/ironic_handlers.py | 6 +- src/templates/parts/section-pxe | 4 +- src/templates/train/ironic.conf | 14 +- src/wheelhouse.txt | 9 ++ 12 files changed, 464 insertions(+), 28 deletions(-) create mode 100644 src/actions.yaml create mode 100755 src/actions/actions.py create mode 120000 src/actions/set-temp-url-secret create mode 100644 src/lib/charm/openstack/ironic/clients.py create mode 100644 src/wheelhouse.txt diff --git a/src/actions.yaml b/src/actions.yaml new file mode 100644 index 0000000..f68c6f6 --- /dev/null +++ b/src/actions.yaml @@ -0,0 +1,13 @@ +set-temp-url-secret: + description: | + Set Temp-Url-Key in the service object storage account. This enables Ironic + to use the "direct" deploy method. In order for this to work, both Glance + and Ironic must use the same tenant in their respective configs. + + This action must be performed on the ironic-conductor leader, after the + deployment is complete. Glance must either use Swift/RadosGW as a storage + backend, or multi-backend must be enabled in Glance with Swift as one of + the supported stores. + + A relation can be created between Glance and RadosGW, which will enable + RadosGW to act as a backend for Glance. diff --git a/src/actions/actions.py b/src/actions/actions.py new file mode 100755 index 0000000..5784516 --- /dev/null +++ b/src/actions/actions.py @@ -0,0 +1,122 @@ +#!/usr/local/sbin/charm-env python3 +# Copyright 2020 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import os +import sys +import traceback +import uuid + +# Load modules from $CHARM_DIR/lib +sys.path.append('lib') +sys.path.append('reactive') + +from charms.layer import basic +basic.bootstrap_charm_deps() +basic.init_config_states() + +import charms.reactive as reactive +import charms.leadership as leadership + +import charms_openstack.bus +import charms_openstack.charm as charm + +import charmhelpers.core as ch_core + +import charm.openstack.ironic.clients as clients + +charms_openstack.bus.discover() + + +def set_temp_url_secret(*args): + """Set Temp-Url-Key on storage account""" + if not reactive.is_flag_set('leadership.is_leader'): + return ch_core.hookenv.action_fail('action must be run on the leader ' + 'unit.') + if not reactive.is_flag_set('config.complete'): + return ch_core.hookenv.action_fail('required relations are not yet ' + 'available, please defer action' + 'until deployment is complete.') + identity_service = reactive.endpoint_from_flag( + 'identity-credentials.available') + try: + keystone_session = clients.create_keystone_session(identity_service) + except Exception as e: + ch_core.hookenv.action_fail('Failed to create keystone session ("{}")' + .format(e)) + + os_cli = clients.OSClients(keystone_session) + if os_cli.has_swift() is False: + ch_core.hookenv.action_fail( + 'Swift not yet available. Please wait for deployment to finish') + + if os_cli.has_glance() is False: + ch_core.hookenv.action_fail( + 'Glance not yet available. Please wait for deployment to finish') + + if "swift" not in os_cli.glance_stores: + ch_core.hookenv.action_fail( + 'Glance does not support Swift storage backend. ' + 'Please add relation between glance and ceph-radosgw/swift') + + current_secret = leadership.leader_get("temp_url_secret") + current_swift_secret = os_cli.get_object_account_properties().get( + 'temp-url-key', None) + + if not current_secret or current_swift_secret != current_secret: + secret = hashlib.sha1( + str(uuid.uuid4()).encode()).hexdigest() + os_cli.set_object_account_property("temp-url-key", secret) + leadership.leader_set({"temp_url_secret": secret}) + # render configs on leader, and assess status. Every other unit + # will render theirs when leader-settings-changed executes. + shared_db = reactive.endpoint_from_flag( + 'shared-db.available') + ironic_api = reactive.endpoint_from_flag( + 'ironic-api.available') + amqp = reactive.endpoint_from_flag( + 'amqp.available') + + with charm.provide_charm_instance() as ironic_charm: + ironic_charm.render_with_interfaces( + charm.optional_interfaces( + (identity_service, shared_db, ironic_api, amqp))) + ironic_charm._assess_status() + + +ACTIONS = { + 'set-temp-url-secret': set_temp_url_secret, +} + + +def main(args): + action_name = os.path.basename(args[0]) + try: + action = ACTIONS[action_name] + except KeyError: + return 'Action {} undefined'.format(action_name) + else: + try: + action(args) + except Exception as e: + ch_core.hookenv.log('action "{}" failed: "{}" "{}"' + .format(action_name, str(e), + traceback.format_exc()), + level=ch_core.hookenv.ERROR) + ch_core.hookenv.action_fail(str(e)) + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/src/actions/set-temp-url-secret b/src/actions/set-temp-url-secret new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/set-temp-url-secret @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/config.yaml b/src/config.yaml index 5f1be76..4a32be3 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -77,24 +77,72 @@ options: Enabling this option will preserve the data on disk after release (not recommended for production). provisioning-network: - default: deployment + default: !!null "" type: string description: | The name or ID of the provisioning network. This network is used to deploy - bare metal nodes. This option is mandatory to allow Neutron network interfaces. + bare metal nodes. This option is mandatory to allow Neutron network interfaces. cleaning-network: - default: deployment + default: !!null "" type: string description: | The name or ID of the cleaning network. This network is used to clean bare metal nodes after they have been releases. This option is mandatory to allow Neutron network interfaces. The same network may be used for both cleaning and provisioning. - extra-pxe-params: - default: "" + enabled-network-interfaces: + type: string + default: "flat, neutron, noop" + description: | + Comma separated list of network interfaces to be enabled in the Ironic config. + Valid options are: + * flat + * neutron + * noop + + Note: When enabling "neutron", you will also have to set the provisioning-network + and the cleaning-network options. The settings for these networks can be overwritten + per node, but they need to be set globally for ironic to start. The "neutron" network + interface is needed if you require additional enablement from a ml2 driver you may + have enabled in your deployment, such as switch configuration. + default-network-interface: + type: string + default: "flat" + description: | + The default network interface to use for nodes that do not explicitly set a network + interface type. The default network interface specified here, must also exist in the + list of enabled-network-interfaces. + enabled-deploy-interfaces: + type: string + default: "direct, iscsi" + description: | + Comma separated list of deploy interfaces to use. + Valid options are: + * direct + * iscsi + + Note: To enable the direct deploy interface, the following conditions must be + met in your deployment of OpenStack: + * ceph-radosgw or swift is deployed and available + * glance is deployed and has a relation set to ceph-radosgw or swift + * You ran the set-temp-url-secret action of this charm + If any of these conditions are not met, the direct deploy interface will not be + enabled in the config, and the charm will go into blocked state. + + Note: The iscsi deploy mode requires that ironic-conductor be deployed on a VM + or a bare metal machine. That is because the iscsi kernel module is not namespaced, + and the ironic-conductor will not be able to log into any iscsi target. + default-deploy-interface: + type: string + default: "direct" + description: | + The default deploy interface to use for nodes that do not explicitly set a deploy + interface. + pxe-append-params: + default: "nofb nomodeset vga=normal console=tty0 console=ttyS0,115200n8" type: string description: | - Additional kernel command line parameters to pass to the deployment kernel. + Kernel command line parameters to pass to the deployment kernel. Options must be space delimited and will be passed as is to the deployment image. Aside from regular linux kernel command line parameters, you can also configure the ironic python agent (IPA) from within the deployment image. See the IPA @@ -104,9 +152,10 @@ options: default: true type: boolean description: | - Enables automated cleaning of nodes. This is run when setting a node to available + Globally enables automated cleaning of nodes. This is run when setting a node to available state, or when deleting an instance. Cleaning will bring the node in a baseline state. You can safely disable this feature if all tenants of your OpenStack deployment are trusted, or if you have a single tenant. + Note: Automated cleaning can be toggled on a per node basis, via node properties. Note: node cleaning may take a long time, especially if secure erase is enabled. diff --git a/src/layer.yaml b/src/layer.yaml index 2f2edab..d4551c9 100644 --- a/src/layer.yaml +++ b/src/layer.yaml @@ -1,4 +1,5 @@ includes: + - layer:leadership - layer:openstack-principle - interface:mysql-shared - interface:rabbitmq @@ -8,4 +9,6 @@ repo: https://github.com/gabriel-samfira/charm-ironic-conductor options: basic: use_venv: true - include_system_packages: true + include_system_packages: False + packages: [ 'libffi-dev', 'libssl-dev', 'libpython3.6-dev' ] + diff --git a/src/lib/charm/openstack/ironic/clients.py b/src/lib/charm/openstack/ironic/clients.py new file mode 100644 index 0000000..c3905f8 --- /dev/null +++ b/src/lib/charm/openstack/ironic/clients.py @@ -0,0 +1,122 @@ +from keystoneauth1 import loading +from keystoneauth1 import session as ks_session +from keystoneauth1 import exceptions as ks_exc + +import glanceclient +import swiftclient +import keystoneclient + + +def create_keystone_session(keystone, verify=True): + plugin_name = "password" + username = keystone.credentials_username() + password = keystone.credentials_password() + + plugin_args = { + "username": username, + "password": password, + } + + project_name = keystone.credentials_project() + + auth_url = "%s://%s:%s" % ( + keystone.auth_protocol(), + keystone.auth_host(), + keystone.credentials_port()) + + plugin_args.update({ + "auth_url": auth_url, + "project_name": project_name, + }) + + keystone_version = keystone.api_version() + if keystone_version and int(keystone_version) == 3: + plugin_name = "v3" + plugin_name + + project_domain_name = keystone.credentials_project_domain_name() + plugin_args["project_domain_name"] = project_domain_name + + user_domain_name = keystone.credentials_user_domain_name() + plugin_args["user_domain_name"] = user_domain_name + + loader = loading.get_plugin_loader(plugin_name) + auth = loader.load_from_options(**plugin_args) + return ks_session.Session(auth=auth, verify=verify) + + +class OSClients(object): + + def __init__(self, session, cacert=None): + self._session = session + self._img_cli = glanceclient.Client( + session=self._session, version=2) + self._obj_cli = swiftclient.Connection( + session=self._session, cacert=cacert) + self._ks = keystoneclient.v3.Client( + session=session) + self._stores = None + + @property + def _stores_info(self): + if self._stores: + return self._stores + self._stores = self._img_cli.images.get_stores_info().get( + "stores", []) + return self._stores + + @property + def glance_stores(self): + return [stor["id"] for stor in self._stores_info] + + def get_default_glance_store(self): + for stor in self._stores_info: + if stor.get("default"): + return stor["id"] + raise ValueError("no default store set") + + def get_object_account_properties(self): + acct = self._obj_cli.get_account() + props = {} + for prop, val in acct[0].items(): + if prop.startswith('x-account-meta-'): + props[prop.replace("x-account-meta-","")] = val + return props + + def set_object_account_property(self, prop, value): + current_props = self.get_object_account_properties() + prop = prop.lower() + if current_props.get(prop, None) == value: + return + meta_key = 'x-account-meta-%s' % prop + headers = { + meta_key: value} + self._obj_cli.post_account(headers) + + def delete_object_account_property(self, prop): + prop = prop.lower() + meta_key = 'x-account-meta-%s' % prop + headers = { + meta_key: ''} + self._obj_cli.post_account(headers) + + def _has_service_type(self, svc_type, interface="public"): + try: + svc_id = self._ks.services.find(type=svc_type) + except ks_exc.http.NotFound: + return False + + try: + self._ks.endpoints.find( + service_id=svc_id.id, interface=interface) + except ks_exc.http.NotFound: + return False + + return True + + def has_swift(self): + return self._has_service_type("object-store") + + def has_glance(self): + return self._has_service_type("image") + + diff --git a/src/lib/charm/openstack/ironic/controller_utils.py b/src/lib/charm/openstack/ironic/controller_utils.py index 550ceb2..87e7921 100644 --- a/src/lib/charm/openstack/ironic/controller_utils.py +++ b/src/lib/charm/openstack/ironic/controller_utils.py @@ -114,3 +114,11 @@ def get_pxe_config_class(charm_config): if series == "bionic": return PXEBootBionic(charm_config) return PXEBootBase(charm_config) + +# TODO: Create keystone session +# TODO: create swift client +# TODO: generate secret +# TODO: set tmp-url-key to secret +# TODO: Config property function that returns the secret from leader data +def set_temp_url_secret(keystone): + pass \ No newline at end of file diff --git a/src/lib/charm/openstack/ironic/ironic.py b/src/lib/charm/openstack/ironic/ironic.py index 9d8614c..d4daac9 100644 --- a/src/lib/charm/openstack/ironic/ironic.py +++ b/src/lib/charm/openstack/ironic/ironic.py @@ -15,9 +15,15 @@ from charms_openstack.adapters import ( import charm.openstack.ironic.controller_utils as controller_utils import charms_openstack.adapters as adapters import charmhelpers.contrib.network.ip as ch_ip +import charms.leadership as leadership +import charms.reactive as reactive PACKAGES = [ 'ironic-conductor', + 'python3-keystoneauth1', + 'python3-keystoneclient', + 'python3-glanceclient', + 'python3-swiftclient', 'python-mysqldb', 'python3-dracclient', 'python3-sushy', @@ -58,6 +64,12 @@ def internal_interface_ip(args): return ch_ip.get_relation_ip("internal") +@adapters.config_property +def temp_url_secret(self): + url_secret = leadership.leader_get("temp_url_secret") + return url_secret + + class IronicAdapters(OpenStackRelationAdapters): relation_adapters = { @@ -102,19 +114,36 @@ class IronicConductorCharm(charms_openstack.charm.OpenStackCharm): } group = "ironic" + mandatory_config = [] def __init__(self, **kw): super().__init__(**kw) self.pxe_config = controller_utils.get_pxe_config_class( self.config) - self.packages.extend(self.pxe_config.determine_packages()) - self.config["tftpboot"] = self.pxe_config.TFTP_ROOT - self.config["httpboot"] = self.pxe_config.HTTP_ROOT - self.config["ironic_user"] = self.pxe_config.IRONIC_USER - self.config["ironic_group"] = self.pxe_config.IRONIC_GROUP - self.restart_map.update(self.pxe_config.get_restart_map()) + self._setup_pxe_config(self.pxe_config) + self._configure_defaults() + if "neutron" in self.enabled_network_interfaces: + self.mandatory_config.extend([ + "provisioning-network", + "cleaning-network"]) + + def _configure_defaults(self): + net_iface = self.config.get("default-network-interface", None) + if not net_iface: + self.config["default-network-interface"] = "flat" + iface = self.config.get("default-deploy-interface", None) + if not iface: + self.config["default-deploy-interface"] = "direct" + + def _setup_pxe_config(self, cfg): + self.packages.extend(cfg.determine_packages()) + self.config["tftpboot"] = cfg.TFTP_ROOT + self.config["httpboot"] = cfg.HTTP_ROOT + self.config["ironic_user"] = cfg.IRONIC_USER + self.config["ironic_group"] = cfg.IRONIC_GROUP + self.restart_map.update(cfg.get_restart_map()) self.services.append( - self.pxe_config.HTTPD_SERVICE_NAME) + cfg.HTTPD_SERVICE_NAME) def install(self): self.configure_source() @@ -134,4 +163,84 @@ class IronicConductorCharm(charms_openstack.charm.OpenStackCharm): dict( database=self.config['database'], username=self.config['database-user'], ) - ] \ No newline at end of file + ] + + def _validate_network_interfaces(self, interfaces): + valid_interfaces = ["flat", "neutron", "noop"] + for interface in interfaces: + if interface not in valid_interfaces: + raise ValueError( + 'Network interface "%s" is not valid. Valid ' + 'interfaces are: %s' % ( + interface, ", ".join(valid_interfaces))) + + def _validate_default_net_interface(self): + net_iface = self.config["default-network-interface"] + if net_iface not in self.enabled_network_interfaces: + raise ValueError( + "default-network-interface (%s) is not enabled " + "in enabled-network-interfaces: %s" % ", ".join( + self.enabled_network_interfaces)) + + def _validate_deploy_interfaces(self, interfaces): + valid_interfaces = ["direct", "iscsi"] + has_secret = reactive.is_flag_set("leadership.set.temp_url_secret") + for interface in interfaces: + if interface not in valid_interfaces: + raise ValueError( + "Deploy interface %s is not valid. Valid " + "interfaces are: %s" % ( + interface, ", ".join(valid_interfaces))) + if reactive.is_flag_set("config.complete"): + if "direct" in interfaces and has_secret is False: + raise ValueError( + 'run "set-temp-url-secret" action on leader to ' + 'enable "direct" deploy method') + + def _validate_default_deploy_interface(self): + iface = self.config["default-deploy-interface"] + if iface not in self.enabled_deploy_interfaces: + raise ValueError( + "default-deploy-interface (%s) is not enabled " + "in enabled-deploy-interfaces: %s" % ", ".join( + self.enabled_deploy_interfaces)) + + @property + def enabled_network_interfaces(self): + network_interfaces = self.config.get( + 'enabled-network-interfaces', "").replace(" ", "") + return network_interfaces.split(",") + + @property + def enabled_deploy_interfaces(self): + network_interfaces = self.config.get( + 'enabled-deploy-interfaces', "").replace(" ", "") + return network_interfaces.split(",") + + def custom_assess_status_check(self): + try: + self._validate_network_interfaces(self.enabled_network_interfaces) + except Exception as err: + msg = ("invalid enabled-network-interfaces config: %s" % err) + return ('blocked', msg) + + try: + self._validate_default_net_interface() + except Exception as err: + msg = ("invalid default-network-interface config: %s" % err) + return ('blocked', msg) + + try: + self._validate_deploy_interfaces( + self.enabled_deploy_interfaces) + except Exception as err: + msg = ("invalid enabled-deploy-interfaces config: %s" % err) + return ('blocked', msg) + + try: + self._validate_default_deploy_interface() + except Exception as err: + msg = ("invalid default-deploy-interface config: %s" % err) + return ('blocked', msg) + + return (None, None) \ No newline at end of file diff --git a/src/reactive/ironic_handlers.py b/src/reactive/ironic_handlers.py index 8b96f49..51e1ab1 100644 --- a/src/reactive/ironic_handlers.py +++ b/src/reactive/ironic_handlers.py @@ -2,12 +2,10 @@ from __future__ import absolute_import import charms.reactive as reactive import charmhelpers.core.hookenv as hookenv +import charms.leadership as leadership import charms_openstack.charm as charm -import charm.openstack.ironic.ironic as ironic # noqa - -from charmhelpers.core.templating import render - +import charm.openstack.ironic.ironic as ironic # Use the charms.openstack defaults for common states and hooks charm.use_defaults( diff --git a/src/templates/parts/section-pxe b/src/templates/parts/section-pxe index d1832b7..06d87e0 100644 --- a/src/templates/parts/section-pxe +++ b/src/templates/parts/section-pxe @@ -9,7 +9,9 @@ tftp_root={{tftpboot}} # value) tftp_server = {{ options.deployment_interface_ip }} -pxe_append_params = nofb nomodeset vga=normal console=tty0 console=ttyS0,115200n8 {{ options.extra_pxe_params }} +{% if options.pxe_append_params -%} +pxe_append_params = {{ options.pxe_append_params }} +{% endif -%} {% if options.use_ipxe -%} # Enable iPXE boot. (boolean value) diff --git a/src/templates/train/ironic.conf b/src/templates/train/ironic.conf index 8f1659a..ef694cd 100644 --- a/src/templates/train/ironic.conf +++ b/src/templates/train/ironic.conf @@ -4,7 +4,7 @@ verbose = {{ options.verbose }} auth_strategy=keystone my_ip = {{ options.internal_interface_ip }} -enabled_deploy_interfaces = iscsi,direct +enabled_deploy_interfaces = {{ options.enabled_deploy_interfaces }} enabled_hardware_types = ipmi,ilo,idrac,redfish,irmc {% if options.use_ipxe -%} enabled_boot_interfaces = pxe,ipxe,ilo-pxe,ilo-ipxe,irmc-pxe @@ -13,15 +13,15 @@ enabled_boot_interfaces = pxe,ilo-pxe,irmc-pxe {% endif -%} enabled_management_interfaces = ipmitool,redfish,ilo,irmc,idrac,noop enabled_inspect_interfaces = idrac,ilo,irmc,redfish,no-inspect -enabled_network_interfaces = flat,neutron,noop +enabled_network_interfaces = {{ options.enabled_network_interfaces }} enabled_power_interfaces = ipmitool,redfish,ilo,irmc,idrac enabled_storage_interfaces = cinder,noop enabled_console_interfaces = ipmitool-socat,ipmitool-shellinabox,no-console enabled_raid_interfaces = agent,idrac,irmc,no-raid enabled_vendor_interfaces = ipmitool,idrac,ilo,no-vendor -default_deploy_interface = iscsi -default_network_interface = flat +default_deploy_interface = {{ options.default_deploy_interface }} +default_network_interface = {{ options.default_network_interface }} transport_url = {{ amqp.transport_url }} @@ -44,10 +44,10 @@ provisioning_network = {{ options.provisioning_network }} [glance] {% include "parts/service-auth" %} -# swift_container = {{ swift_container }} -# swift_temp_url_key = {{ tmp_url_key }} swift_container = glance -swift_temp_url_key = secret +{% if options.temp_url_secret -%} +swift_temp_url_key = {{ options.temp_url_secret }} +{% endif %} [swift] {% include "parts/service-auth" %} diff --git a/src/wheelhouse.txt b/src/wheelhouse.txt new file mode 100644 index 0000000..a0cbdfb --- /dev/null +++ b/src/wheelhouse.txt @@ -0,0 +1,9 @@ +# Several dependencies now require setuptools-scm>=3.0,<=3.4.1 which requires toml +setuptools-scm>=3.0,<=3.4.1 +toml +keystoneauth1 +pbr +python-glanceclient +python-swiftclient +python-keystoneclient +zipp < 2.0.0