1166 lines
41 KiB
Diff
1166 lines
41 KiB
Diff
|
diff --color -uNr a/configure.ac b/configure.ac
|
||
|
--- a/configure.ac 2024-10-18 12:30:47.834626309 +0200
|
||
|
+++ b/configure.ac 2024-10-18 12:32:15.620672697 +0200
|
||
|
@@ -1010,6 +1010,7 @@
|
||
|
AC_CONFIG_FILES([heartbeat/mpathpersist], [chmod +x heartbeat/mpathpersist])
|
||
|
AC_CONFIG_FILES([heartbeat/nfsnotify], [chmod +x heartbeat/nfsnotify])
|
||
|
AC_CONFIG_FILES([heartbeat/openstack-info], [chmod +x heartbeat/openstack-info])
|
||
|
+AC_CONFIG_FILES([heartbeat/powervs-subnet], [chmod +x heartbeat/powervs-subnet])
|
||
|
AC_CONFIG_FILES([heartbeat/rabbitmq-cluster], [chmod +x heartbeat/rabbitmq-cluster])
|
||
|
AC_CONFIG_FILES([heartbeat/redis], [chmod +x heartbeat/redis])
|
||
|
AC_CONFIG_FILES([heartbeat/rsyslog], [chmod +x heartbeat/rsyslog])
|
||
|
diff --color -uNr a/doc/man/Makefile.am b/doc/man/Makefile.am
|
||
|
--- a/doc/man/Makefile.am 2024-10-18 12:30:47.801625540 +0200
|
||
|
+++ b/doc/man/Makefile.am 2024-10-18 12:33:57.763053742 +0200
|
||
|
@@ -190,6 +190,7 @@
|
||
|
ocf_heartbeat_portblock.7 \
|
||
|
ocf_heartbeat_postfix.7 \
|
||
|
ocf_heartbeat_pound.7 \
|
||
|
+ ocf_heartbeat_powervs-subnet.7 \
|
||
|
ocf_heartbeat_proftpd.7 \
|
||
|
ocf_heartbeat_rabbitmq-cluster.7 \
|
||
|
ocf_heartbeat_redis.7 \
|
||
|
diff --color -uNr a/.gitignore b/.gitignore
|
||
|
--- a/.gitignore 2024-10-18 12:30:47.801625540 +0200
|
||
|
+++ b/.gitignore 2024-10-18 10:45:57.222895499 +0200
|
||
|
@@ -22,6 +22,7 @@
|
||
|
make/stamp-h1
|
||
|
make/clusterautoconfig.h*
|
||
|
missing
|
||
|
+resource-agents.spec
|
||
|
*.pc
|
||
|
.deps
|
||
|
.libs
|
||
|
@@ -76,6 +77,7 @@
|
||
|
heartbeat/mpathpersist
|
||
|
heartbeat/nfsnotify
|
||
|
heartbeat/openstack-info
|
||
|
+heartbeat/powervs-subnet
|
||
|
heartbeat/rabbitmq-cluster
|
||
|
heartbeat/redis
|
||
|
heartbeat/rsyslog
|
||
|
diff --color -uNr a/heartbeat/Makefile.am b/heartbeat/Makefile.am
|
||
|
--- a/heartbeat/Makefile.am 2024-10-18 12:30:47.801625540 +0200
|
||
|
+++ b/heartbeat/Makefile.am 2024-10-18 12:33:02.884774474 +0200
|
||
|
@@ -165,6 +165,7 @@
|
||
|
portblock \
|
||
|
postfix \
|
||
|
pound \
|
||
|
+ powervs-subnet \
|
||
|
proftpd \
|
||
|
rabbitmq-cluster \
|
||
|
redis \
|
||
|
diff --color -uNr a/heartbeat/powervs-subnet.in b/heartbeat/powervs-subnet.in
|
||
|
--- a/heartbeat/powervs-subnet.in 1970-01-01 01:00:00.000000000 +0100
|
||
|
+++ b/heartbeat/powervs-subnet.in 2024-10-18 12:31:09.071121354 +0200
|
||
|
@@ -0,0 +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
|
||
|
+
|
||
|
+ 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. 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.
|
||
|
+
|
||
|
+ 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.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=False,
|
||
|
+ 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()
|