diff --git a/heartbeat/powervs-subnet.in b/heartbeat/powervs-subnet.in index 087623060..83a468e02 100755 --- a/heartbeat/powervs-subnet.in +++ b/heartbeat/powervs-subnet.in @@ -1,1106 +1,1109 @@ #!@PYTHON@ -tt # ------------------------------------------------------------------------ # Description: Resource Agent to move a Power Virtual Server subnet # and its IP address from one virtual server instance # to another. # # Authors: Edmund Haefele # Walter Orb # # Copyright (c) 2024 International Business Machines, Inc. # # 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 ipaddress import json import math import os import re import socket import subprocess import sys import textwrap import time import requests import requests.adapters import urllib3.util OCF_FUNCTIONS_DIR = os.environ.get( "OCF_FUNCTIONS_DIR", "%s/lib/heartbeat" % os.environ.get("OCF_ROOT") ) sys.path.append(OCF_FUNCTIONS_DIR) try: import ocf except ImportError: sys.stderr.write("ImportError: ocf module import failed.") sys.exit(5) class PowerCloudAPIError(Exception): def __init__(self, message, exit_code): ocf.ocf_exit_reason(message) sys.exit(exit_code) class nmcli: """A wrapper class to run nmcli system commands.""" NMCLI_SYSTEM_CMD = ["nmcli", "-t"] CONN_PREFIX = "VIP_" DEV_PREFIX = "env" ROUTING_PRIO = 50 ROUTING_TABLE = ocf.get_parameter("route_table", 500) _WAIT_FOR_NIC_SLEEP = 3 def __init__(self): """Class implements only classmethods or staticmethods, instantiation is not used.""" pass @classmethod def _nmcli_os_cmd(cls, nmcli_args): """run os nmcli command with the specified arguments. Returns the output as a dictionary. """ ocf.logger.debug("_nmcli_os_cmd: args: {}".format(nmcli_args)) output = None try: result = subprocess.run( cls.NMCLI_SYSTEM_CMD + nmcli_args, capture_output=True, text=True, check=True, env={"LANG": "C"}, ) if len(nmcli_args) == 1 or nmcli_args[0] == "-g" or nmcli_args[1] == "show": # return output as dict output = dict( item.split(":", 1) for item in result.stdout.rstrip().splitlines() if ":" in item ) except subprocess.CalledProcessError as e: raise PowerCloudAPIError( f"_nmcli_os_cmd: error executing nmcli: {e.stderr}", ocf.OCF_ERR_GENERIC, ) return output @classmethod def _nmcli_cmd(cls, command, subcommand=None, name=None, **kwargs): """Prepare arguments to call nmcli command.""" ocf.logger.debug( f"_nmcli_cmd: args: command: {command}, subcommand: {subcommand}, name: {name}" ) if command in ["connection", "device"]: nmcli_args = [command] else: raise PowerCloudAPIError( f"_nmcli_cmd: nmcli {command} not implemented", ocf.OCF_ERR_GENERIC, ) if name: if subcommand in ("show", "delete", "down", "up"): nmcli_args += [subcommand, name] elif subcommand == "add": nmcli_args += [subcommand, "type", "ethernet", "con-name", name] else: raise PowerCloudAPIError( f"_nmcli_cmd: nmcli {command} {subcommand} not implemented", ocf.OCF_ERR_GENERIC, ) elif subcommand in ("add", "delete", "down", "up"): raise PowerCloudAPIError( f"_nmcli_cmd: name argument required for nmcli {command} {subcommand}", ocf.OCF_ERR_GENERIC, ) options = kwargs.get("options", {}) for k, v in options.items(): nmcli_args += [k, v] return cls._nmcli_os_cmd(nmcli_args) @classmethod def _nmcli_find(cls, command, match_key, match_value): """Find the network object whose attribute with the specified key matches the specified value.""" ocf.logger.debug( f"_nmcli_find: args: command: {command}, key: {match_key}, value: {match_value}" ) nm_object = None for name in cls._nmcli_cmd(command=command, subcommand="show"): if not re.search(f"({cls.CONN_PREFIX})?{cls.DEV_PREFIX}", name): # check only connections or devices with device prefix in name continue obj_attrs = cls._nmcli_cmd(command=command, subcommand="show", name=name) if re.search(match_value, obj_attrs.get(match_key, "")): ocf.logger.debug(f"_nmcli_find: found match: name: {name}") nm_object = obj_attrs break return nm_object @classmethod def cleanup(cls): """Clean up orphaned Network Manager connections.""" connections = cls._nmcli_os_cmd(["-g", "UUID,NAME,ACTIVE", "connection"]) for uuid in connections: name, active = connections[uuid].split(":") if active == "no" and name.startswith(f"{cls.CONN_PREFIX}{cls.DEV_PREFIX}"): ocf.logger.debug(f"nmcli.cleanup: delete orphaned connection {name}") nmcli.connection.delete(uuid) @classmethod def wait_for_nic(cls, mac, timeout=720): """Wait for a NIC with a given MAC address to become available.""" ocf.logger.debug(f"wait_for_nic: args: mac: {mac}, timeout: {timeout} s") mac_address = mac.upper() retries = math.ceil((timeout * 0.95) / cls._WAIT_FOR_NIC_SLEEP) - 1 for attempt in range(1, retries + 1): try: ocf.logger.debug( f"wait_for_nic: waiting for nic with mac address {mac_address} ..." ) nm_object = cls._nmcli_find("device", "GENERAL.HWADDR", mac_address) if nm_object: break finally: time.sleep(cls._WAIT_FOR_NIC_SLEEP) else: # no break raise PowerCloudAPIError( f"wait_for_nic: timeout while waiting for nic with MAC address {mac_address}", ocf.OCF_ERR_GENERIC, ) nic = nm_object.get("GENERAL.DEVICE") wait_time = (attempt - 1) * cls._WAIT_FOR_NIC_SLEEP ocf.logger.info( f"wait_for_nic: found network device {nic} with MAC address {mac_address} after waiting {wait_time} seconds" ) return nic @classmethod def find_gateway(cls, ip): """Find the gateway address for a given IP.""" ocf.logger.debug(f"find_gateway: args: ip: {ip}") gateway = None ip_address = ip.split("/")[0] dev = cls._nmcli_find("device", "IP4.ADDRESS[1]", ip_address) if dev: # Sample IP4.ROUTE[2]: dst = 0.0.0.0/0, nh = 10.10.10.101, mt = 102, table=200 # extract next hop (nh) value ip4_route2 = dict( item.split("=") for item in dev["IP4.ROUTE[2]"].replace(" ", "").split(",") ) gateway = ip4_route2.get("nh", None) return gateway class connection: """Provides methods to run nmcli connection commands.""" @staticmethod def show(name=None, **kwargs): return nmcli._nmcli_cmd("connection", "show", name, **kwargs) @staticmethod def add(name, **kwargs): return nmcli._nmcli_cmd("connection", "add", name, **kwargs) @staticmethod def delete(name, **kwargs): return nmcli._nmcli_cmd("connection", "delete", name, **kwargs) @staticmethod def down(name, **kwargs): return nmcli._nmcli_cmd("connection", "down", name, **kwargs) @staticmethod def up(name, **kwargs): return nmcli._nmcli_cmd("connection", "up", name, **kwargs) @staticmethod def find(match_key, match_value): return nmcli._nmcli_find("connection", match_key, match_value) class device: """Provides methods to run nmcli device commands.""" @staticmethod def show(name=None, **kwargs): return nmcli._nmcli_cmd("device", "show", name, **kwargs) @staticmethod def find(match_key, match_value): return nmcli._nmcli_find("device", match_key, match_value) class PowerCloudAPI: """Provides methods to manage Power Virtual Server resources through its REST API.""" _URL_IAM_GLOBAL = "https://iam.cloud.ibm.com/identity/token" + _URL_IAM_PRIVATE = "https://private.iam.cloud.ibm.com/identity/token" _URL_API_PUBLIC = "https://{}.power-iaas.cloud.ibm.com" _URL_API_PRIVATE = "https://private.{}.power-iaas.cloud.ibm.com" _URL_API_BASE = "/pcloud/v1/cloud-instances/{}" _HTTP_MAX_RETRIES = 10 _HTTP_BACKOFF_FACTOR = 0.4 _HTTP_STATUS_FORCE_RETRIES = (500, 502, 503, 504) _HTTP_RETRY_ALLOWED_METHODS = frozenset({"GET", "POST", "DELETE"}) _START_TIME = time.time() _RESOURCE_ACTION_TIMEOUT = int( int(os.environ.get("OCF_RESKEY_CRM_meta_timeout", 7200000)) / 1000 ) def __init__( self, ip="", cidr="", subnet_name="", api_key="", api_type="", region="", crn_host_map="", vsi_host_map="", proxy="", jumbo="", use_remote_workspace=False, ): """Initialize class variables, including the API token, Cloud Resource Name (CRN), IBM Power Cloud API endpoint URL, and HTTP header.""" self._res_options = locals() self._validate_and_set_options() self._set_api_key() self._set_token() self._set_header() self._instance_check_status() self.network_id = self._subnet_search_by_cidr() def _rest_create_session(self): """Create a request session with a retry strategy.""" # Define the retry strategy retry_strategy = urllib3.util.Retry( total=self._HTTP_MAX_RETRIES, # Maximum number of retries status_forcelist=self._HTTP_STATUS_FORCE_RETRIES, # HTTP status codes to retry on allowed_methods=self._HTTP_RETRY_ALLOWED_METHODS, # Allowed methods for retry operation backoff_factor=self._HTTP_BACKOFF_FACTOR, # Sleep for {backoff factor} * (2 ** ({number of previous retries})) ) # Create an HTTP adapter with the retry strategy and mount it to session adapter = requests.adapters.HTTPAdapter(max_retries=retry_strategy) # Create a new session object session = requests.Session() session.mount("https://", adapter) self._session = session return session def _rest_api_call(self, method, resource, **kwargs): """Perform a REST call to the specified URL.""" url = self._url + self._base + resource method = method.upper() ocf.logger.debug(f"_rest_api_call: {method} {resource}") session = self._session or self._rest_create_session() r = session.request( method, url, headers=self._header, proxies=self._proxy, **kwargs ) if not r.ok: raise PowerCloudAPIError( f"_rest_api_call: {method} call {resource} to {url} failed with reason: {r.reason}, status code: {r.status_code}", ocf.OCF_ERR_GENERIC, ) return r.json() def _set_api_key(self): """Store an API key in a class variable. api_key is a string. If the first character of the string is @, the rest of the string is assumed to be the name of a file containing the API key. """ api_key = self._res_options["api_key"] if api_key[0] == "@": api_key_file = api_key[1:] try: with open(api_key_file, "r") as f: # read the API key from a file try: keys = json.loads(f.read()) # data seems to be in json format # return the value of the item with the key 'Apikey' # backward compatibility: In the past, the key name was 'apikey' api_key = keys.get("Apikey", "") if not api_key: api_key = keys.get("apikey", "") except ValueError: # data is text, return as is api_key = f.read().strip() except FileNotFoundError: raise PowerCloudAPIError( f"_set_api_key: API key file '{api_key_file}' not found", ocf.OCF_ERR_ARGS, ) self._api_key = api_key def _set_token(self): """Use the stored API key to obtain an IBM Cloud IAM access token.""" - url = self._URL_IAM_GLOBAL + url = self._URL_IAM headers = { "content-type": "application/x-www-form-urlencoded", "accept": "application/json", } data = { "grant_type": "urn:ibm:params:oauth:grant-type:apikey", "apikey": f"{self._api_key}", } token_response = requests.post( url, headers=headers, data=data, proxies=self._proxy ) if token_response.status_code != 200: raise PowerCloudAPIError( f"_set_token: failed to obtain token from IBM Cloud IAM: {token_response.status_code}", ocf.OCF_ERR_GENERIC, ) self._token = json.loads(token_response.text)["access_token"] def _set_header(self): """Set the Cloud Resource Name (CRN), IBM Power Cloud API endpoint URL, and HTTP header.""" self._header = { "Authorization": f"Bearer {self._token}", "CRN": f"{self._crn}", "Content-Type": "application/json", } def _instance_check_status(self): """Check if instance exists in workspace and log the current status.""" resource = f"/pvm-instances/{self.instance_id}" instance = self._rest_api_call("GET", resource) server_name = instance["serverName"] status = instance["status"] health = instance["health"]["status"] if status == "SHUTOFF" or (status == "ACTIVE" and health == "OK"): ocf.logger.debug( f"_instance_check_status: OK server_name: {server_name}, status: {status}, health: {health}" ) else: if not (self._ocf_action == "monitor"): raise PowerCloudAPIError( f"_instance_check_status: FAIL server_name: {server_name}, status: {status}, health: {health}", ocf.OCF_ERR_GENERIC, ) def _instance_subnet_is_attached(self): """Check if a virtual server instance is connected to a specific subnet.""" for net in self._instance_subnet_list(): if self.network_id == net["networkID"]: return True return False def _instance_subnet_get(self): """Obtain information about a particular subnet connected to a virtual server instance.""" resource = f"/pvm-instances/{self.instance_id}/networks/{self.network_id}" response = self._rest_api_call("GET", resource) return response["networks"][0] def _instance_subnet_list(self): """List all subnets connected to a virtual server instance.""" resource = f"/pvm-instances/{self.instance_id}/networks" response = self._rest_api_call("GET", resource) return response["networks"] def _instance_subnet_attach(self): """Attach a subnet to a virtual server instance.""" data = ( f'{{"networkID":"{self.network_id}","ipAddress":"{self.ip}"}}' if self.ip else f'{{"networkID":"{self.network_id}"}}' ) resource = f"/pvm-instances/{self.instance_id}/networks/" _ = self._rest_api_call("POST", resource, data=data) def _instance_subnet_detach(self): """Detach a subnet from a virtual server instance.""" resource = f"/pvm-instances/{self.instance_id}/networks/{self.network_id}" _ = self._rest_api_call("DELETE", resource) def _subnet_create(self): """Create a subnet in the workspace.""" data = ( f'{{"type":"vlan","cidr":"{self.cidr}","mtu":9000,"name":"{self.subnet_name}"}}' if self.jumbo else f'{{"type":"vlan","cidr":"{self.cidr}","name":"{self.subnet_name}"}}' ) resource = "/networks" response = self._rest_api_call("POST", resource, data=data) self.network_id = response["networkID"] def _subnet_delete(self): """Delete a subnet in the workspace.""" resource = f"/networks/{self.network_id}" _ = self._rest_api_call("DELETE", resource) def _subnet_get(self, network_id): """Get information about a specific subnet in the workspace.""" resource = f"/networks/{network_id}" response = self._rest_api_call("GET", resource) return response def _subnet_list(self): """List all subnets in the workspace.""" resource = "/networks/" response = self._rest_api_call("GET", resource) return response def _subnet_search_by_cidr(self): """Find the subnet for a given CIDR.""" for network in self._subnet_list()["networks"]: network_id = network["networkID"] if self.cidr == self._subnet_get(network_id)["cidr"]: return network_id return None def _subnet_port_get_all(self): """Obtain information about the ports for a specific subnet.""" resource = f"/networks/{self.network_id}/ports" response = self._rest_api_call("GET", resource) return response["ports"] def _subnet_port_delete(self, port_id): """Delete an orphaned port for a particular subnet.""" resource = f"/networks/{self.network_id}/ports/{port_id}" _ = self._rest_api_call("DELETE", resource) def _subnet_port_get_reserved(self): """Check if a port is already reserved on the subnet for the IP address.""" for port in self._subnet_port_get_all(): if self.ip == port["ipAddress"]: return port["portID"] return None def _validate_and_set_options(self): """Validate the options of the resource agent and derive class variables from the options.""" self._ocf_action = os.environ.get("__OCF_ACTION") if self._ocf_action is None and len(sys.argv) == 2: self._ocf_action = sys.argv[1] ip = self._res_options["ip"] try: validated_ip = ipaddress.ip_address(ip) except ValueError: raise PowerCloudAPIError( f"_validate_and_set_options: {ip} is not a valid IP address.", ocf.OCF_ERR_CONFIGURED, ) self.ip = ip cidr = self._res_options["cidr"] try: validated_cidr = ipaddress.ip_network(cidr) except ValueError: raise PowerCloudAPIError( f"_validate_and_set_options: {cidr} is not a valid CIDR notation.", ocf.OCF_ERR_CONFIGURED, ) self.cidr = cidr if validated_ip not in validated_cidr: raise PowerCloudAPIError( f"_validate_and_set_options: {ip} is not in {cidr} range.", ocf.OCF_ERR_CONFIGURED, ) subnet_name = self._res_options["subnet_name"] self.subnet_name = subnet_name if subnet_name else self.cidr crn_host_map = self._res_options["crn_host_map"] try: self._crn_host_map = dict( item.split(":", 1) for item in crn_host_map.split(";") ) except ValueError: raise PowerCloudAPIError( f"_validate_and_set_options: crn_host_map: {crn_host_map} has an invalid format.", ocf.OCF_ERR_CONFIGURED, ) self._hostname = os.uname().nodename if self._res_options["use_remote_workspace"]: self._nodename = [k for k in self._crn_host_map if k != self._hostname][0] else: self._nodename = self._hostname if self._nodename not in self._crn_host_map: raise PowerCloudAPIError( f"_validate_and_set_options: {self._nodename} not found in crn_host_map: {crn_host_map}.", ocf.OCF_ERR_ARGS, ) self._crn = self._crn_host_map[self._nodename] try: self._cloud_instance_id = self._crn.split(":")[7] except IndexError: raise PowerCloudAPIError( f"_validate_and_set_options: {self._crn} is not a valid CRN.", ocf.OCF_ERR_CONFIGURED, ) vsi_host_map = self._res_options["vsi_host_map"] try: self._vsi_host_map = dict( item.split(":") for item in vsi_host_map.split(";") ) except ValueError: raise PowerCloudAPIError( f"_validate_and_set_options: Option vsi_host_map: {vsi_host_map} has an invalid format.", ocf.OCF_ERR_CONFIGURED, ) if self._nodename not in self._vsi_host_map: raise PowerCloudAPIError( f"_validate_and_set_options: {self._nodename} not found in vsi_host_map: {vsi_host_map}.", ocf.OCF_ERR_ARGS, ) self.instance_id = self._vsi_host_map[self._nodename] jumbo = self._res_options["jumbo"].lower() if ocf.is_true(jumbo): self.jumbo = True else: if jumbo not in ("no", "false", "0", 0, "nein", "off", False): raise PowerCloudAPIError( f"_validate_and_set_options: option jumbo: {jumbo} does not match True or False.", ocf.OCF_ERR_CONFIGURED, ) self.jumbo = False # Check connect to proxy server self._proxy = "" proxy = self._res_options["proxy"] if proxy: # extract ip address and port match = re.search(r"^https?://([^:]+):(\d+)$", proxy) if match: proxy_ip, proxy_port = match.group(1), match.group(2) try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(30) s.connect((proxy_ip, int(proxy_port))) except socket.error: raise PowerCloudAPIError( f"_validate_and_set_options: cannot connect to port {proxy_port} at {proxy_ip}, check option proxy: {proxy}.", ocf.OCF_ERR_CONFIGURED, ) self._proxy = {"https": f"{proxy}"} else: raise PowerCloudAPIError( f"_validate_and_set_options: the option proxy: {proxy} has an invalid format.", ocf.OCF_ERR_CONFIGURED, ) api_type = self._res_options["api_type"] if api_type not in ("public", "private"): raise PowerCloudAPIError( f"_validate_and_set_options: option api_type: {api_type} does not match public or private.", ocf.OCF_ERR_CONFIGURED, ) # Set API endpoint url url_api_fmt = ( self._URL_API_PRIVATE if api_type == "private" else self._URL_API_PUBLIC ) self._url = url_api_fmt.format(self._res_options["region"]) + self._URL_IAM = ( + self._URL_IAM_PRIVATE if api_type == "private" else self._URL_IAM_GLOBAL + ) self._base = self._URL_API_BASE.format(self._cloud_instance_id) self._session = None def subnet_add(self): """Create and attach subnet in local workspace""" ocf.logger.debug( f"subnet_add: options: ip: {self.ip}, cidr: {self.cidr}, name: {self.subnet_name}" ) if self.network_id: ocf.logger.debug( f"subnet_add: subnet cidr: {self.cidr} already exists with network id: {self.network_id}" ) else: ocf.logger.debug( f"subnet_add: create subnet name: {self.subnet_name} with cidr: {self.cidr} and jumbo: {self.jumbo}" ) self._subnet_create() if self._instance_subnet_is_attached(): ocf.logger.debug( f"subnet_add: subnet id {self.network_id} is already attached to instance id {self.instance_id}" ) else: ocf.logger.debug( f"subnet_add: attach subnet id: {self.network_id} to instance id: {self.instance_id} (IP address {self.ip})" ) self._instance_subnet_attach() subnet = self._subnet_get(self.network_id) gateway = subnet["gateway"] port = self._instance_subnet_get() mac = port["macAddress"] ip_address = port["ipAddress"] self.jumbo = subnet.get("mtu", "") == 9000 timeout = self._RESOURCE_ACTION_TIMEOUT - int(time.time() - self._START_TIME) nic = nmcli.wait_for_nic(mac, timeout) return nic, ip_address, mac, gateway def subnet_remove(self): """Detach and delete subnet in local or remote workspace""" ocf.logger.debug( f"subnet_remove: options: cidr: {self.cidr}, network id: {self.network_id}, instance id: {self.instance_id}" ) if self.network_id: ocf.logger.debug( f"subnet_remove: subnet id: {self.network_id} with cidr: {self.cidr} exists" ) if self._instance_subnet_is_attached(): ocf.logger.debug( f"subnet_remove: subnet id: {self.network_id} is attached to instance id {self.instance_id}" ) port = self._instance_subnet_get() mac = port["macAddress"] dev = nmcli.device.find("GENERAL.HWADDR", mac.upper()) if dev: nm_object = nmcli.connection.find( "GENERAL.IP-IFACE", dev["GENERAL.DEVICE"] ) if nm_object: conn_name = nm_object["connection.id"] ocf.logger.debug( f"stop_action: unconfigure network connection conn_name: {conn_name} with mac address {mac}" ) nmcli.connection.down(conn_name) nmcli.connection.delete(conn_name) ocf.logger.debug( f"subnet_remove: detach network id: {self.network_id} from instance id: {self.instance_id}" ) self._instance_subnet_detach() port_id = self._subnet_port_get_reserved() if port_id: ocf.logger.debug( f"subnet_remove: delete port port_id: {port_id} for subnet network id: {self.network_id}" ) self._subnet_port_delete(port_id) ocf.logger.debug(f"subnet_remove: delete network id: {self.network_id}") self._subnet_delete() def os_ping(ip): """Ping an IP address.""" command = ["ping", "-c", "1", ip] response = subprocess.call(command) return response == 0 def start_action( ip="", cidr="", subnet_name="", api_key="", api_type="", region="", crn_host_map="", vsi_host_map="", proxy="", jumbo="", ): """start_action: assign the service ip. Create a subnet in the workspace, connect it to the virtual server instance, and configure the NIC. """ res_options = locals() ocf.logger.info(f"start_action: options: {res_options}") # Detach and remove subnet in remote workspace remote_ws = PowerCloudAPI(**res_options, use_remote_workspace=True) ocf.logger.debug( f"start_action: remove subnet from remote workspace: cidr: {remote_ws.cidr}" ) remote_ws.subnet_remove() # Delete orphaned Network Manager connections nmcli.cleanup() # Create and attach subnet in local workspace ws = PowerCloudAPI(**res_options) nic, ip_address, mac, gateway = ws.subnet_add() ocf.logger.debug( f"start_action: add nmcli connection: nic: {nic}, ip: {ip_address}, mac: {mac}, gateway: {gateway}, jumbo: {ws.jumbo}, table {nmcli.ROUTING_TABLE}" ) conn_name = f"{nmcli.CONN_PREFIX}{nic}" conn_options = { "ifname": nic, "autoconnect": "no", "ipv4.method": "manual", "ipv4.addresses": ip_address, "ipv4.routes": f"0.0.0.0/0 {gateway} table={nmcli.ROUTING_TABLE}", "ipv4.routing-rules": f"priority {nmcli.ROUTING_PRIO} from {ws.cidr} table {nmcli.ROUTING_TABLE}", } if ws.jumbo: conn_options.update({"802-3-ethernet.mtu": "9000", "ethtool.feature-tso": "on"}) nmcli.connection.add(conn_name, options=conn_options) nmcli.connection.up(conn_name) if monitor_action(**res_options) != ocf.OCF_SUCCESS: raise PowerCloudAPIError(f"start_action: start subnet: {ws.subnet_name} failed") ocf.logger.info( f"start_action: finished, added connection {conn_name} for subnet {ws.subnet_name}" ) return ocf.OCF_SUCCESS def stop_action( ip="", cidr="", subnet_name="", api_key="", api_type="", region="", crn_host_map="", vsi_host_map="", proxy="", jumbo="", ): """stop_action: unassign the service ip. Delete NIC, detach subnet from virtual server instance, and delete subnet. """ res_options = locals() ocf.logger.info(f"stop_action: options: {res_options}") ws = PowerCloudAPI(**res_options) ws.subnet_remove() if monitor_action(**res_options) != ocf.OCF_NOT_RUNNING: raise PowerCloudAPIError(f"stop_action: stop subnet {ws.subnet_name} failed") ocf.logger.info( f"stop_action: finished, deleted connection for subnet {ws.subnet_name}" ) return ocf.OCF_SUCCESS def monitor_action( ip="", cidr="", subnet_name="", api_key="", api_type="", region="", crn_host_map="", vsi_host_map="", proxy="", jumbo="", ): """monitor_action: check if service ip and gateway are responding.""" res_options = locals() is_probe = ocf.is_probe() ocf.logger.debug(f"monitor_action: options: {res_options}, is_probe: {is_probe}") gateway = nmcli.find_gateway(ip) if gateway and os_ping(gateway): if os_ping(ip): ocf.logger.debug( f"monitor_action: ping to gateway: {gateway} and ip: {ip} successful" ) return ocf.OCF_SUCCESS else: raise PowerCloudAPIError( f"monitor_action: ping to ip: {ip} failed", ocf.OCF_ERR_GENERIC ) if not is_probe: ocf.logger.error(f"monitor_action: ping to gateway: {gateway} failed") ws = PowerCloudAPI(**res_options) ocf.logger.debug(f"monitor_action: instance id: {ws.instance_id}") if not ws.network_id or is_probe: return ocf.OCF_NOT_RUNNING # monitor should never reach this code, exit with raise raise PowerCloudAPIError( f"monitor_action: unknown problem with subnet id: {ws.network_id}", ocf.OCF_ERR_GENERIC, ) def validate_all_action( ip="", cidr="", subnet_name="", api_key="", api_type="", region="", crn_host_map="", vsi_host_map="", proxy="", jumbo="", ): """validate_all_action: Validate the resource agent parameters.""" res_options = locals() # The class instantiation validates the resource agent options and that the instance exists try: # Check instance in local workspace _ = PowerCloudAPI(**res_options, use_remote_workspace=False) except Exception: ocf.logger.error( "validate_all_action: failed to instantiate class in local workspace." ) raise try: # Check instance in remote workspace _ = PowerCloudAPI(**res_options, use_remote_workspace=True) except Exception: ocf.logger.error( "validate_all_action: failed to instantiate class in remote workspace." ) raise return ocf.OCF_SUCCESS def main(): """Instantiate the resource agent.""" agent_description = textwrap.dedent("""\ Resource Agent to move a Power Virtual Server subnet and its IP address from one virtual server instance to another. The prerequisites for the use of this resource agent are as follows: 1. Red Hat Enterprise Linux 9.2 or higher: Install with @server group to ensure that NetworkManager settings are correct. Verify that the NetworkManager-config-server package is installed. - 2. IBM Cloud API Key: + 2. A two-node cluster that is distributed across two different Power Virtual Server workspaces in two data centers in a region. + + 3. IBM Cloud API Key: Create a service API key that is privileged for both Power Virtual Server workspaces. Save the service API key in a file and copy the file to both cluster nodes. Use same filename and directory location on both cluster nodes. Reference the path to the key file in the resource definition. - 3. The hostname of the virtual server instances must be same as the name - of the virtual server instances in the Power Virtual Server workspaces. - For comprehensive documentation on implementing high availability for SAP applications on IBM Power Virtual Server, visit https://cloud.ibm.com/docs/sap?topic=sap-ha-overview. """) agent = ocf.Agent( "powervs-subnet", shortdesc="Manages moving a Power Virtual Server subnet", longdesc=agent_description, - version=1.03, + version=1.04, ) agent.add_parameter( "ip", shortdesc="IP address", longdesc=( "IP address within the subnet. The IP address moves together with the subnet." ), content_type="string", required=True, ) agent.add_parameter( "cidr", shortdesc="CIDR", longdesc="Classless Inter-Domain Routing (CIDR) of the subnet.", content_type="string", required=True, ) agent.add_parameter( "subnet_name", shortdesc="Name of the subnet", longdesc="Name of the subnet. If not specified, CIDR is used as name.", content_type="string", required=False, ) agent.add_parameter( "api_type", shortdesc="API type", longdesc="Connect to Power Virtual Server regional endpoints over a public or private network (public|private).", content_type="string", required=True, default="private", ) agent.add_parameter( "region", shortdesc="Power Virtual Server region", longdesc=( "Region that represents the geographic area where the instance is located. " "The region is used to identify the Cloud API endpoint." ), content_type="string", required=True, ) agent.add_parameter( "api_key", shortdesc="API Key or @API_KEY_FILE_PATH", longdesc=( "API Key or @API_KEY_FILE_PATH for IBM Cloud access. " "The API key content or the path of an API key file that is indicated by the @ symbol." ), content_type="string", required=True, ) agent.add_parameter( "crn_host_map", shortdesc="Mapping of hostnames to IBM Cloud CRN", longdesc=( "Map the hostname of the Power Virtual Server instance to the CRN of the Power Virtual Server workspaces hosting the instance. " "Separate hostname and CRN with a colon ':', separate different hostname and CRN pairs with a semicolon ';'. " "Example: hostname01:CRN-of-Instance01;hostname02:CRN-of-Instance02" ), content_type="string", required=True, ) agent.add_parameter( "vsi_host_map", shortdesc="Mapping of hostnames to PowerVS instance ids", longdesc=( "Map the hostname of the Power Virtual Server instance to its instance id. " "Separate hostname and instance id with a colon ':', separate different hostname and instance id pairs with a semicolon ';'. " "Example: hostname01:instance-id-01;hostname02:instance-id-02" ), content_type="string", required=True, ) agent.add_parameter( "proxy", shortdesc="Proxy", longdesc="Proxy server to access IBM Cloud API endpoints.", content_type="string", required=False, ) agent.add_parameter( "jumbo", shortdesc="Use Jumbo frames", longdesc="Create a Power Virtual Server subnet with an MTU size of 9000 (true|false).", content_type="string", required=False, default="false", ) agent.add_parameter( "route_table", shortdesc="route table ID", longdesc="ID of the route table for the interface. Default is 500.", content_type="string", required=False, default="500", ) agent.add_action("start", timeout=900, handler=start_action) agent.add_action("stop", timeout=450, handler=stop_action) agent.add_action( "monitor", depth=0, timeout=60, interval=60, handler=monitor_action ) agent.add_action("validate-all", timeout=300, handler=validate_all_action) agent.run() if __name__ == "__main__": main()