From 7e7123baaa16e96cfd7c9c5d64e3153c02195e1a Mon Sep 17 00:00:00 2001 From: AlmaLinux RelEng Bot Date: Wed, 29 Apr 2026 07:36:57 -0400 Subject: [PATCH] import Oracle_OSS resource-agents-4.16.0-22.el10_1.9 --- ...113106-podman-etcd-add-oom-parameter.patch | 0 ... => RHEL-113751-1-podman-etcd-new-ra.patch | 0 ...-remove-unused-actions-from-metadata.patch | 0 ...an-etcd-fix-listen-peer-urls-binding.patch | 0 RHEL-114494-1-powervs-move-ip-new-ra.patch | 1127 +++++++++++++++++ ...owervs-move-ip-add-iflabel-parameter.patch | 197 +++ ...f-shellfuncs-add-ocf_promotion_score.patch | 19 + ...7-2-portblock-add-promotable-support.patch | 362 ++++++ ...d-method-and-status_check-parameters.patch | 180 +++ ...197-4-portblock-check-inverse-action.patch | 156 +++ ...126121-1-nginx-fix-validate-warnings.patch | 0 ...-pid-file-during-validate-all-action.patch | 0 ...-ip-powervs-subnet-fix-error-logging.patch | 54 + RHEL-81238-powervs-subnet-wait-for-IP.patch | 43 + resource-agents.spec | 55 +- 15 files changed, 2180 insertions(+), 13 deletions(-) rename RHEL-113104-podman-etcd-add-oom-parameter.patch => RHEL-113106-podman-etcd-add-oom-parameter.patch (100%) rename RHEL-88431-1-podman-etcd-new-ra.patch => RHEL-113751-1-podman-etcd-new-ra.patch (100%) rename RHEL-88431-2-podman-etcd-remove-unused-actions-from-metadata.patch => RHEL-113751-2-podman-etcd-remove-unused-actions-from-metadata.patch (100%) rename RHEL-88431-3-podman-etcd-fix-listen-peer-urls-binding.patch => RHEL-113751-3-podman-etcd-fix-listen-peer-urls-binding.patch (100%) create mode 100644 RHEL-114494-1-powervs-move-ip-new-ra.patch create mode 100644 RHEL-114494-2-powervs-move-ip-add-iflabel-parameter.patch create mode 100644 RHEL-116197-1-ocf-shellfuncs-add-ocf_promotion_score.patch create mode 100644 RHEL-116197-2-portblock-add-promotable-support.patch create mode 100644 RHEL-116197-3-portblock-fixes-add-method-and-status_check-parameters.patch create mode 100644 RHEL-116197-4-portblock-check-inverse-action.patch rename RHEL-112443-1-nginx-fix-validate-warnings.patch => RHEL-126121-1-nginx-fix-validate-warnings.patch (100%) rename RHEL-112443-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch => RHEL-126121-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch (100%) create mode 100644 RHEL-143528-powervs-move-ip-powervs-subnet-fix-error-logging.patch create mode 100644 RHEL-81238-powervs-subnet-wait-for-IP.patch diff --git a/RHEL-113104-podman-etcd-add-oom-parameter.patch b/RHEL-113106-podman-etcd-add-oom-parameter.patch similarity index 100% rename from RHEL-113104-podman-etcd-add-oom-parameter.patch rename to RHEL-113106-podman-etcd-add-oom-parameter.patch diff --git a/RHEL-88431-1-podman-etcd-new-ra.patch b/RHEL-113751-1-podman-etcd-new-ra.patch similarity index 100% rename from RHEL-88431-1-podman-etcd-new-ra.patch rename to RHEL-113751-1-podman-etcd-new-ra.patch diff --git a/RHEL-88431-2-podman-etcd-remove-unused-actions-from-metadata.patch b/RHEL-113751-2-podman-etcd-remove-unused-actions-from-metadata.patch similarity index 100% rename from RHEL-88431-2-podman-etcd-remove-unused-actions-from-metadata.patch rename to RHEL-113751-2-podman-etcd-remove-unused-actions-from-metadata.patch diff --git a/RHEL-88431-3-podman-etcd-fix-listen-peer-urls-binding.patch b/RHEL-113751-3-podman-etcd-fix-listen-peer-urls-binding.patch similarity index 100% rename from RHEL-88431-3-podman-etcd-fix-listen-peer-urls-binding.patch rename to RHEL-113751-3-podman-etcd-fix-listen-peer-urls-binding.patch diff --git a/RHEL-114494-1-powervs-move-ip-new-ra.patch b/RHEL-114494-1-powervs-move-ip-new-ra.patch new file mode 100644 index 0000000..e94214e --- /dev/null +++ b/RHEL-114494-1-powervs-move-ip-new-ra.patch @@ -0,0 +1,1127 @@ +From 3e15ecb7457e55f39fc5e48eecf250937819f8c5 Mon Sep 17 00:00:00 2001 +From: ehaefele <30649454+ehaefele@users.noreply.github.com> +Date: Fri, 12 Sep 2025 12:17:17 +0200 +Subject: [PATCH] powervs-move-ip: new resource agent (#2072) + +* powervs-move-ip: new resource agent + +Resource agent to move a virtual IP address between two virtual server instances, +and manage the corresponding network routes in the IBM Power Virtual Server workspaces. +--- + .gitignore | 1 + + configure.ac | 8 + + doc/man/Makefile.am | 4 + + heartbeat/Makefile.am | 4 + + heartbeat/powervs-move-ip.in | 1035 ++++++++++++++++++++++++++++++++++ + 5 files changed, 1052 insertions(+) + create mode 100755 heartbeat/powervs-move-ip.in + +diff --git a/.gitignore b/.gitignore +index 0a6d45e65..8dd29db29 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -79,6 +79,7 @@ heartbeat/mariadb + heartbeat/mpathpersist + heartbeat/nfsnotify + heartbeat/openstack-info ++heartbeat/powervs-move-ip + heartbeat/powervs-subnet + heartbeat/rabbitmq-cluster + heartbeat/redis +diff --git a/configure.ac b/configure.ac +index 8a74e6684..3765ac858 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -560,6 +560,13 @@ if test -z "$PYTHON" || test $BUILD_OCF_PY -eq 0; then + fi + AM_CONDITIONAL(BUILD_GCP_VPC_MOVE_VIP, test $BUILD_GCP_VPC_MOVE_VIP -eq 1) + ++BUILD_POWERVS_MOVE_IP=1 ++if test -z "$PYTHON" || test $BUILD_OCF_PY -eq 0 || test "x${HAVE_PYMOD_REQUESTS}" != xyes || test "x${HAVE_PYMOD_URLLIB3}" != xyes; then ++ BUILD_POWERVS_MOVE_IP=0 ++ AC_MSG_WARN("Not building powervs-move-ip") ++fi ++AM_CONDITIONAL(BUILD_POWERVS_MOVE_IP, test $BUILD_POWERVS_MOVE_IP -eq 1) ++ + BUILD_POWERVS_SUBNET=1 + if test -z "$PYTHON" || test $BUILD_OCF_PY -eq 0 || test "x${HAVE_PYMOD_REQUESTS}" != xyes || test "x${HAVE_PYMOD_URLLIB3}" != xyes; then + BUILD_POWERVS_SUBNET=0 +@@ -1044,6 +1051,7 @@ AC_CONFIG_FILES([heartbeat/mariadb], [chmod +x heartbeat/mariadb]) + 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-move-ip], [chmod +x heartbeat/powervs-move-ip]) + 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]) +diff --git a/doc/man/Makefile.am b/doc/man/Makefile.am +index 0d34c7c65..0dee5e9e1 100644 +--- a/doc/man/Makefile.am ++++ b/doc/man/Makefile.am +@@ -238,6 +238,10 @@ if BUILD_GCP_VPC_MOVE_VIP + man_MANS += ocf_heartbeat_gcp-vpc-move-vip.7 + endif + ++if BUILD_POWERVS_MOVE_IP ++man_MANS += ocf_heartbeat_powervs-move-ip.7 ++endif ++ + if BUILD_POWERVS_SUBNET + man_MANS += ocf_heartbeat_powervs-subnet.7 + endif +diff --git a/heartbeat/Makefile.am b/heartbeat/Makefile.am +index 839505af9..b5374163d 100644 +--- a/heartbeat/Makefile.am ++++ b/heartbeat/Makefile.am +@@ -207,6 +207,10 @@ if BUILD_GCP_VPC_MOVE_VIP + ocf_SCRIPTS += gcp-vpc-move-vip + endif + ++if BUILD_POWERVS_MOVE_IP ++ocf_SCRIPTS += powervs-move-ip ++endif ++ + if BUILD_POWERVS_SUBNET + ocf_SCRIPTS += powervs-subnet + endif +diff --git a/heartbeat/powervs-move-ip.in b/heartbeat/powervs-move-ip.in +new file mode 100755 +index 000000000..d55979e52 +--- /dev/null ++++ b/heartbeat/powervs-move-ip.in +@@ -0,0 +1,1035 @@ ++#!@PYTHON@ -tt ++# ------------------------------------------------------------------------ ++# Description: Resource agent for moving an overlay IP address between ++# virtual server instances in different PowerVS workspaces. ++# ++# Authors: Edmund Haefele ++# Walter Orb ++# ++# Copyright (c) 2025 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 fcntl ++import ipaddress ++import json ++import os ++import socket ++import subprocess ++import sys ++import textwrap ++import time ++from pathlib import Path ++from urllib.parse import urlparse ++ ++import requests ++import requests.adapters ++import urllib3.util ++ ++# Constants ++OCF_FUNCTIONS_DIR = os.environ.get( ++ "OCF_FUNCTIONS_DIR", "%s/lib/heartbeat" % os.environ.get("OCF_ROOT") ++) ++RESOURCE_OPTIONS = ( ++ "ip", ++ "api_key", ++ "api_type", ++ "region", ++ "route_host_map", ++ "use_token_cache", ++ "monitor_api", ++ "device", ++ "proxy", ++) ++IP_CMD = "/usr/sbin/ip" ++REQUESTS_TIMEOUT = 5 # Timeout for requests calls ++HTTP_MAX_RETRIES = 3 # Maximum number of retries for HTTP requests ++HTTP_BACKOFF_FACTOR = 0.3 # Sleep (factor * (2^number of previous retries)) secs ++HTTP_STATUS_FORCE_RETRIES = (500, 502, 503, 504) # HTTP status codes to retry on ++HTTP_RETRY_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "DELETE"}) ++CIDR_NETMASK = "32" ++ ++sys.path.append(OCF_FUNCTIONS_DIR) ++try: ++ import ocf ++except ImportError: ++ sys.stderr.write("ImportError: ocf module import failed.") ++ sys.exit(5) ++ ++ ++class OCFExitError(Exception): ++ """Exception class for OCF (Open Cluster Framework) exit errors.""" ++ ++ def __init__(self, message, exit_code): ++ ocf.ocf_exit_reason(message) ++ sys.exit(exit_code) ++ ++ ++class CmdError(OCFExitError): ++ """Exception class for errors when running system commands.""" ++ ++ def __init__(self, message, exit_code): ++ super().__init__(f"[CmdError] {message}", exit_code) ++ ++ ++def os_cmd(cmd_args, is_json=False, timeout=10): ++ """Run a system command and optionally parse JSON output.""" ++ ocf.logger.debug(f"[os_cmd]: args: {cmd_args}") ++ try: ++ result = subprocess.run( ++ cmd_args, ++ capture_output=True, ++ text=True, ++ check=True, ++ timeout=timeout, ++ env={"LANG": "C"}, ++ ) ++ if is_json: ++ try: ++ return json.loads(result.stdout) ++ except json.JSONDecodeError as e: ++ raise CmdError(f"os_cmd: JSON parsing failed: {e}", ocf.OCF_ERR_GENERIC) ++ ++ return result.returncode ++ ++ except subprocess.CalledProcessError as e: ++ raise CmdError( ++ f"os_cmd: command failed: {e.stderr}", ++ ocf.OCF_ERR_GENERIC, ++ ) ++ except subprocess.TimeoutExpired: ++ raise CmdError("os_cmd: command timed out", ocf.OCF_ERR_GENERIC) ++ ++ ++def ip_cmd(*args, is_json=False): ++ """Generic wrapper for the ip command.""" ++ return os_cmd([IP_CMD] + list(args), is_json=is_json) ++ ++ ++def ip_address_show(): ++ """Show IP addresses in JSON format.""" ++ return ip_cmd("-json", "address", "show", is_json=True) ++ ++ ++def ip_address_add(cidr, device, label=None): ++ """Add an IP address to a device.""" ++ cmd = ["address", "add", cidr, "dev", device] ++ if label: ++ cmd += ["label", label] ++ return ip_cmd(*cmd) ++ ++ ++def ip_address_delete(cidr, device): ++ """Delete an IP address from a device.""" ++ return ip_cmd("address", "delete", cidr, "dev", device) ++ ++ ++def ip_find_device(ip): ++ """Find the device associated with a given IP address.""" ++ for iface in ip_address_show(): ++ addresses = [a["local"] for a in iface["addr_info"]] ++ if ip in addresses and "UP" in iface["flags"]: ++ return iface["ifname"] ++ ++ return None ++ ++ ++def ip_check_device(device): ++ """Verify that a device with the specified interface name (device) exists.""" ++ for iface in ip_address_show(): ++ if iface["ifname"] == device and "UP" in iface["flags"]: ++ return True ++ ++ return False ++ ++ ++def ip_alias_add(ip, device): ++ """Add an IP alias to the given device.""" ++ ip_cidr = f"{ip}/{CIDR_NETMASK}" ++ ocf.logger.debug( ++ f"[ip_alias_add]: adding IP alias '{ip_cidr}' to interface '{device}'" ++ ) ++ _ = ip_address_add(ip_cidr, device) ++ ++ ++def ip_alias_remove(ip): ++ """Find the device with the given IP alias and remove the alias.""" ++ device = ip_find_device(ip) ++ if device: ++ ip_cidr = f"{ip}/{CIDR_NETMASK}" ++ ocf.logger.debug( ++ f"[ip_alias_remove]: removing IP alias '{ip_cidr}' from interface '{device}'" ++ ) ++ _ = ip_address_delete(ip_cidr, device) ++ ++ ++def create_session_with_retries(): ++ """Create a request session with a retry strategy.""" ++ retry_strategy = urllib3.util.Retry( ++ total=HTTP_MAX_RETRIES, ++ status_forcelist=HTTP_STATUS_FORCE_RETRIES, ++ allowed_methods=HTTP_RETRY_ALLOWED_METHODS, ++ backoff_factor=HTTP_BACKOFF_FACTOR, ++ raise_on_status=False, ++ ) ++ adapter = requests.adapters.HTTPAdapter(max_retries=retry_strategy) ++ session = requests.Session() ++ session.mount("https://", adapter) ++ return session ++ ++ ++class PowerCloudTokenManagerError(OCFExitError): ++ """Exception class for errors in the PowerCloudTokenManager.""" ++ ++ def __init__(self, message, exit_code): ++ super().__init__(f"[PowerCloudTokenManagerError] {message}", exit_code) ++ ++ ++class PowerCloudTokenManager: ++ """Request and cache IBM Cloud tokens.""" ++ ++ _DEFAULT_RESOURCE_INSTANCE = "powervs-move-ip" ++ _TOKEN_REFRESH_BUFFER = 900 # 15 minutes ++ ++ def __init__( ++ self, ++ api_type="", ++ api_key="", ++ proxy="", ++ use_cache=False, ++ ): ++ self._auth_url = ( ++ "https://private.iam.cloud.ibm.com/identity/token" ++ if api_type == "private" ++ else "https://iam.cloud.ibm.com/identity/token" ++ ) ++ self._api_key = self._load_api_key(api_key) ++ self._proxy = proxy ++ self._session = create_session_with_retries() ++ self._cache_file = None ++ ++ if use_cache: ++ resource_instance = os.environ.get( ++ "OCF_RESOURCE_INSTANCE", self._DEFAULT_RESOURCE_INSTANCE ++ ) ++ self._cache_file = Path( ++ f"/var/run/resource-agents/{resource_instance}-token.json" ++ ) ++ self._cache_file.parent.mkdir(parents=True, exist_ok=True) ++ if not self._cache_file.exists(): ++ self._cache_file.touch() ++ os.chmod(self._cache_file, 0o600) ++ ++ def _load_api_key(self, api_key): ++ """Load API key from string or file.""" ++ if not api_key: ++ raise PowerCloudTokenManagerError( ++ "_load_api_key: API key is missing", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ # API key in string ++ if not api_key.startswith("@"): ++ return api_key ++ ++ # API key in file ++ api_key_path = Path(api_key[1:]) ++ if not api_key_path.is_file(): ++ raise PowerCloudTokenManagerError( ++ f"_load_api_key: API key file not found: '{api_key_path}'", ++ ocf.OCF_ERR_ARGS, ++ ) ++ ++ try: ++ content = api_key_path.read_text().strip() ++ api_key_field = json.loads(content).get("apikey", "") ++ except json.JSONDecodeError: ++ # data is text, return as is ++ api_key_field = content ++ ++ if not api_key_field: ++ raise PowerCloudTokenManagerError( ++ f"_load_api_key: invalid API key in file '{api_key_path}'", ++ ocf.OCF_ERR_ARGS, ++ ) ++ ++ return api_key_field ++ ++ def _request_new_token(self): ++ """Request a new access token.""" ++ 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}", ++ } ++ ++ current_time = time.time() ++ try: ++ response = self._session.post( ++ self._auth_url, ++ headers=headers, ++ data=data, ++ proxies=self._proxy, ++ timeout=REQUESTS_TIMEOUT, ++ ) ++ response.raise_for_status() ++ token_data = response.json() ++ return ( ++ token_data["access_token"], ++ current_time + token_data["expires_in"], ++ current_time, ++ ) ++ except requests.RequestException as e: ++ ocf.logger.warning( ++ f"[PowerCloudTokenManager] _request_new_token: failed to request token: '{e}'" ++ ) ++ return None ++ ++ def _read_cache(self): ++ """Read token cache.""" ++ try: ++ with self._cache_file.open("r") as f: ++ fcntl.flock(f, fcntl.LOCK_EX) ++ try: ++ return json.load(f) ++ finally: ++ fcntl.flock(f, fcntl.LOCK_UN) ++ except (json.JSONDecodeError, FileNotFoundError, PermissionError): ++ ocf.logger.warning( ++ "[PowerCloudTokenManager] _read_cache: failed to read token cache read due to missing file or malformed JSON." ++ ) ++ return {} ++ ++ def _write_cache(self, token, expiration, refreshed_at): ++ """Write token cache.""" ++ try: ++ with self._cache_file.open("w") as f: ++ fcntl.flock(f, fcntl.LOCK_EX) ++ try: ++ json.dump( ++ { ++ "token": token, ++ "expiration": expiration, ++ "refreshed_at": refreshed_at, ++ }, ++ f, ++ ) ++ finally: ++ fcntl.flock(f, fcntl.LOCK_UN) ++ except Exception as e: ++ raise PowerCloudTokenManagerError( ++ f"_write_cache: failed to write token cache file: '{e}'", ++ ocf.OCF_ERR_GENERIC, ++ ) ++ ++ def _is_token_expired(self, expiration): ++ """Check if token is expired or near expiry.""" ++ return time.time() + self._TOKEN_REFRESH_BUFFER >= expiration ++ ++ def get_token(self): ++ """Get a valid access token, using cache if enabled.""" ++ if not self._cache_file: ++ result = self._request_new_token() ++ if result: ++ token, _, _ = result ++ return token ++ raise PowerCloudTokenManagerError( ++ "get_token: token request failed and no cache available", ++ ocf.OCF_ERR_GENERIC, ++ ) ++ ++ cache = self._read_cache() ++ token = cache.get("token") ++ expiration = cache.get("expiration", 0) ++ ++ if not token or self._is_token_expired(expiration): ++ result = self._request_new_token() ++ if result: ++ token, expiration, refreshed_at = result ++ refresh_time = time.ctime(refreshed_at) ++ ocf.logger.debug( ++ f"[PowerCloudTokenManager] get_token: refreshed token at '{refresh_time}'" ++ ) ++ self._write_cache(token, expiration, refreshed_at) ++ else: ++ ocf.logger.error( ++ "[PowerCloudTokenManager] get_token: failed to refresh token" ++ ) ++ if token and time.time() < expiration: ++ ocf.logger.warning( ++ "[PowerCloudTokenManager] get_token: using cached token as fallback" ++ ) ++ else: ++ raise PowerCloudTokenManagerError( ++ "get_token: no valid token available", ++ ocf.OCF_ERR_GENERIC, ++ ) ++ ++ return token ++ ++ ++class PowerCloudAPIError(OCFExitError): ++ """Exception class for errors in PowerCloudAPI.""" ++ ++ def __init__(self, message, exit_code): ++ super().__init__(f"[PowerCloudAPIError] {message}", exit_code) ++ ++ ++class PowerCloudAPI: ++ """Offers a convenient method for sending requests to the IBM Power Cloud API.""" ++ ++ _ALLOWED_API_TYPES = {"public", "private"} ++ ++ def __init__( ++ self, ++ api_key="", ++ api_type="", ++ region="", ++ crn="", ++ proxy="", ++ use_cache=False, ++ ): ++ """Initialize class variables, including the IBM Power Cloud API endpoint URL and HTTP header, and get an API token.""" ++ ++ self._crn = crn ++ self._proxy = self._get_proxy(proxy) ++ self._api_url = self._get_api_url(region, api_type) ++ token_manager = PowerCloudTokenManager( ++ api_type=api_type, api_key=api_key, proxy=self._proxy, use_cache=use_cache ++ ) ++ self._token = token_manager.get_token() ++ self._header = self._get_header() ++ self._session = create_session_with_retries() ++ ++ def _get_proxy(self, proxy): ++ """Validate a proxy URL and test TCP connectivity. Returns a proxy dict if reachable.""" ++ if not proxy: ++ return None ++ ++ parsed_url = urlparse(proxy) ++ is_valid_url = ( ++ parsed_url.hostname ++ and parsed_url.port ++ and parsed_url.scheme in ("http", "https") ++ ) ++ ++ if not is_valid_url: ++ raise PowerCloudAPIError( ++ f"_get_proxy: invalid proxy URL '{proxy}'", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ try: ++ with socket.create_connection( ++ (parsed_url.hostname, parsed_url.port), timeout=REQUESTS_TIMEOUT ++ ): ++ return {"https": proxy} ++ except OSError as e: ++ raise PowerCloudAPIError( ++ f"_get_proxy: cannot connect to proxy '{proxy}': {e}", ++ ocf.OCF_ERR_ARGS, ++ ) ++ ++ def _get_api_url(self, region, api_type): ++ """Generate and return the API URL for a given region and API type.""" ++ if not region: ++ raise PowerCloudAPIError( ++ "_get_api_url: missing region parameter", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ api_type = str(api_type).lower() ++ if api_type not in self._ALLOWED_API_TYPES: ++ raise PowerCloudAPIError( ++ f"_get_api_url: invalid api_type: '{api_type}', must be one of {self._ALLOWED_API_TYPES} ", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ if api_type == "public" and not self._proxy: ++ raise PowerCloudAPIError( ++ "_get_api_url: api_type 'public' requires a proxy", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ subdomain = "private." if api_type == "private" else "" ++ return f"https://{subdomain}{region}.power-iaas.cloud.ibm.com" ++ ++ def _get_header(self): ++ """Construct request header.""" ++ return { ++ "Authorization": f"Bearer {self._token}", ++ "CRN": self._crn, ++ "Content-Type": "application/json", ++ } ++ ++ def send_api_request(self, method, resource, **kwargs): ++ """Perform an HTTP API call to the specified resource using the given method""" ++ url = f"{self._api_url}{resource}" ++ method = method.upper() ++ ocf.logger.debug(f"[PowerCloudAPI] send_api_request: '{method}' '{resource}'") ++ ++ try: ++ response = self._session.request( ++ method, ++ url, ++ headers=self._header, ++ proxies=self._proxy, ++ timeout=REQUESTS_TIMEOUT, ++ **kwargs, ++ ) ++ response.raise_for_status() ++ return response.json() ++ except requests.RequestException as e: ++ raise PowerCloudAPIError( ++ f"send_api_request: request error occured: '{method}' - '{url}' - '{e}'", ++ ocf.OCF_ERR_GENERIC, ++ ) ++ ++ ++class PowerCloudRouteError(OCFExitError): ++ """Exception class for errors encountered while managing PowerVS network routes.""" ++ ++ def __init__(self, message, exit_code): ++ super().__init__(f"[PowerCloudRouteError] {message}", exit_code) ++ ++ ++class PowerCloudRoute(PowerCloudAPI): ++ """Provides methods for managing network routes in Power Virtual Server.""" ++ ++ _CRN_PREFIX_INDEX = 0 ++ _CRN_TYPE_INDEX = 8 ++ _CRN_ROUTE_ID_INDEX = 9 ++ _CRN_EXPECTED_LENGTH = 10 ++ ++ def __init__( ++ self, ++ ip="", ++ api_key="", ++ api_type="", ++ region="", ++ route_host_map="", ++ device="", ++ proxy="", ++ monitor_api="", ++ use_token_cache="", ++ is_remote_route=False, ++ ): ++ """Initialize PowerCloudRoute instance.""" ++ self._is_remote_route = is_remote_route ++ self.ip = self._get_ip_info(ip) ++ self.crn, self.route_id = self._parse_route_map(route_host_map) ++ use_cache = str(use_token_cache).lower() == "true" ++ super().__init__( ++ api_key=api_key, ++ api_type=api_type, ++ region=region, ++ crn=self.crn, ++ proxy=proxy, ++ use_cache=use_cache, ++ ) ++ self.route_info = self._get_route_info() ++ self.route_name = self.route_info["name"] ++ self.device = self._get_device_name(device) ++ ++ def _get_ip_info(self, ip): ++ """Validate the given IP address and return its standard form.""" ++ try: ++ return str(ipaddress.ip_address(ip)) ++ except ValueError: ++ raise PowerCloudRouteError( ++ f"_get_ip_info: invalid IP address '{ip}'", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ def _parse_route_crn(self, route_crn): ++ """Parses a PowerVS route CRN and extract its base CRN and route ID.""" ++ crn_parts = route_crn.split(":") ++ ++ if ( ++ len(crn_parts) != self._CRN_EXPECTED_LENGTH ++ or crn_parts[self._CRN_PREFIX_INDEX] != "crn" ++ or crn_parts[self._CRN_TYPE_INDEX] != "route" ++ ): ++ raise PowerCloudAPIError( ++ f"_parse_route_crn: invalid CRN format for network-route: '{route_crn}'", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ workspace_crn = ":".join(crn_parts[: self._CRN_TYPE_INDEX]) + "::" ++ route_id = crn_parts[self._CRN_ROUTE_ID_INDEX] ++ ++ return workspace_crn, route_id ++ ++ def _parse_route_map(self, route_host_map): ++ """Validate the route host map and extract the associated CRN and route ID.""" ++ try: ++ route_map = dict(item.split(":", 1) for item in route_host_map.split(";")) ++ except ValueError: ++ raise PowerCloudRouteError( ++ f"_parse_route_map: invalid route_host_map format: '{route_host_map}'", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ hostname = os.uname().nodename ++ # set nodename to local hostname or get hostname of remote host from route_map ++ nodename = ( ++ hostname ++ if not self._is_remote_route ++ else next((h for h in route_map if h != hostname), None) ++ ) ++ ++ if not nodename or nodename not in route_map: ++ raise PowerCloudRouteError( ++ f"_parse_route_map: hostname '{nodename}' not found in route_host_map '{route_host_map}'", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ return self._parse_route_crn(route_map[nodename]) ++ ++ def _get_route_info(self): ++ """Retrieve and validate attributes of a PowerVS network route.""" ++ resource = f"/v1/routes/{self.route_id}" ++ route_info = self.send_api_request("GET", resource) ++ ++ zone = "remote" if self._is_remote_route else "local" ++ ocf.logger.debug( ++ f"[PowerCloudRoute] _get_route_info: {zone} route info: '{route_info}'" ++ ) ++ ++ if self.ip != route_info["destination"]: ++ raise PowerCloudRouteError( ++ f"_get_route_info: IP '{self.ip}' does not match the route destination address '{route_info['destination']}'", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ if route_info["advertise"] != "enable": ++ raise PowerCloudRouteError( ++ f"_get_route_info: route '{route_info['name']}' advertise flag must be set to enable", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ return route_info ++ ++ def _get_device_name(self, name): ++ """Verify the existence of a network interface with the specified name.""" ++ if self._is_remote_route: ++ return "" ++ ++ if name: ++ if ip_check_device(name): ++ return name ++ raise PowerCloudRouteError( ++ f"_get_device_name: network interface '{name}' does not exist or is down", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ next_hop = self.route_info["nextHop"] ++ interface_name = ip_find_device(next_hop) ++ if interface_name: ++ return interface_name ++ ++ raise PowerCloudRouteError( ++ f"_get_device_name: network interface with next hop '{next_hop}' does not exist or is down", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ def _set_route_enabled(self, enabled: bool): ++ """Enable or disable the PowerVS network route.""" ++ resource = f"/v1/routes/{self.route_id}" ++ data = json.dumps({"enabled": enabled}) ++ ++ state = "enabled" if enabled else "disabled" ++ response = self.send_api_request("PUT", resource, data=data) ++ ocf.logger.debug( ++ f"[PowerCloudRoute] _set_route_enabled: successfully {state} route '{self.route_name}', response: '{response}'" ++ ) ++ ++ def is_enabled(self): ++ """Check whether the PowerVS network route is currently enabled.""" ++ return self.route_info["state"] == "deployed" ++ ++ def enable(self): ++ """Enable the PowerVS network route.""" ++ if not self.is_enabled(): ++ self._set_route_enabled(True) ++ ++ def disable(self): ++ """Disable the PowerVS network route.""" ++ if self.is_enabled(): ++ self._set_route_enabled(False) ++ ++ ++def create_route_instance(options, is_remote_route=False, catch_exception=False): ++ """Instantiate a PowerCloudRoute object and handle errors. ++ ++ Returns: ++ - PowerCloudRoute: The initialized route object if successful. ++ - None: If an error occurs and catch_exception is True. ++ ++ Raises: ++ - PowerCloudRouteError: If instantiation fails and catch_exception is False. ++ """ ++ # Filter only the valid resource agent options from options dictionary. ++ resource_options = {k: options.get(k, "") for k in RESOURCE_OPTIONS} ++ ++ try: ++ return PowerCloudRoute(**resource_options, is_remote_route=is_remote_route) ++ except Exception as e: ++ zone = "remote" if is_remote_route else "local" ++ ocf.logger.error( ++ f"[create_route_instance]: failed to instantiate {zone} route: '{e}'" ++ ) ++ if catch_exception: ++ return None ++ raise ++ ++ ++def start_action( ++ ip="", ++ api_key="", ++ api_type="", ++ region="", ++ route_host_map="", ++ use_token_cache="", ++ monitor_api="", ++ device="", ++ proxy="", ++): ++ """Assign the service IP. ++ ++ This function performs the following actions: ++ - Adds the specified IP address as an alias to the given network interface or the interface matching the route's next hop. ++ - Disables the remote network route. ++ - Enables the network route associated with the provided route host map. ++ """ ++ resource_options = locals() ++ ++ ocf.logger.info("[start_action]: enabling overlay IP") ++ ocf.logger.debug(f"[start_action]: options: '{resource_options}'") ++ ++ remote_route = create_route_instance(resource_options, is_remote_route=True) ++ # Disable remote route ++ ocf.logger.debug( ++ f"[start_action]: disabling remote route '{remote_route.route_name}'" ++ ) ++ remote_route.disable() ++ ++ local_route = create_route_instance(resource_options) ++ ++ # Add IP alias ++ ip_alias_add(ip, local_route.device) ++ ++ # Enable local route ++ ocf.logger.debug(f"[start_action]: enabling local route '{local_route.route_name}'") ++ local_route.enable() ++ ++ monitor_result = monitor_action(**resource_options) ++ if monitor_result != ocf.OCF_SUCCESS: ++ raise PowerCloudRouteError( ++ f"start_action: failed to enable local route '{local_route.route_name}'", ++ monitor_result, ++ ) ++ ++ ocf.logger.info( ++ f"[start_action]: successfully added IP alias '{ip}' and enabled local route '{local_route.route_name}'" ++ ) ++ return ocf.OCF_SUCCESS ++ ++ ++def stop_action( ++ ip="", ++ api_key="", ++ api_type="", ++ region="", ++ route_host_map="", ++ use_token_cache="", ++ monitor_api="", ++ device="", ++ proxy="", ++): ++ """Remove the service IP. ++ ++ This function performs the following actions: ++ - Disables the network route associated with the provided route host map. ++ - Removes the IP alias from the network interface. ++ """ ++ ++ resource_options = locals() ++ ++ ocf.logger.info("[stop_action]: disabling overlay IP") ++ ocf.logger.debug(f"[stop_action]: options: '{resource_options}'") ++ ++ try: ++ remote_route = create_route_instance(resource_options, is_remote_route=True) ++ ocf.logger.debug( ++ f"[stop_action]: disabling remote route '{remote_route.route_name}'" ++ ) ++ remote_route.disable() ++ ++ local_route = create_route_instance(resource_options) ++ ocf.logger.debug( ++ f"[stop_action]: disabling local route '{local_route.route_name}'" ++ ) ++ local_route.disable() ++ finally: ++ # Remove IP alias ++ ip_alias_remove(ip) ++ ++ monitor_result = monitor_action(**resource_options) ++ if monitor_result != ocf.OCF_NOT_RUNNING: ++ raise PowerCloudRouteError( ++ f"stop_action: failed to disable local route '{local_route.route_name}'", ++ monitor_result, ++ ) ++ ++ ocf.logger.info( ++ f"[stop_action]: successfully removed IP alias '{ip}' and disabled local route '{local_route.route_name}'" ++ ) ++ return ocf.OCF_SUCCESS ++ ++ ++def monitor_action( ++ ip="", ++ api_key="", ++ api_type="", ++ region="", ++ route_host_map="", ++ use_token_cache="", ++ monitor_api="", ++ device="", ++ proxy="", ++): ++ """Monitor the service IP. ++ ++ Checks the status of the assigned service IP address. ++ """ ++ resource_options = locals() ++ is_probe = ocf.is_probe() ++ use_extended_monitor = ocf.OCF_ACTION == "start" or ( ++ str(monitor_api).lower() == "true" and not is_probe ++ ) ++ ++ ocf.logger.debug( ++ f"[monitor_action]: options: '{resource_options}', is_probe: '{is_probe}'" ++ ) ++ ++ interface_name = ip_find_device(ip) ++ ++ if not use_extended_monitor: ++ if interface_name: ++ ocf.logger.debug( ++ f"[monitor_action]: IP alias '{ip}' is active'" ++ ) ++ return ocf.OCF_SUCCESS ++ else: ++ ocf.logger.debug( ++ f"[monitor_action]: IP alias '{ip}' is not active" ++ ) ++ return ocf.OCF_NOT_RUNNING ++ ++ remote_route = create_route_instance( ++ resource_options, is_remote_route=True, catch_exception=True ++ ) ++ if remote_route is None: ++ ocf.logger.error("[monitor_action]: failed to instantiate remote route") ++ return ocf.OCF_ERR_GENERIC ++ elif remote_route.is_enabled(): ++ ocf.logger.error( ++ f"[monitor_action]: remote route '{remote_route.route_name}' is enabled" ++ ) ++ return ocf.OCF_ERR_GENERIC ++ ++ local_route = create_route_instance( ++ resource_options, is_remote_route=False, catch_exception=True ++ ) ++ ++ if local_route is None: ++ ocf.logger.error("[monitor_action]: failed to instantiate local route") ++ return ocf.OCF_ERR_GENERIC ++ ++ if interface_name: ++ if local_route.is_enabled(): ++ ocf.logger.debug( ++ f"[monitor_action]: IP alias '{ip}' is active, local route '{local_route.route_name}' is enabled" ++ ) ++ return ocf.OCF_SUCCESS ++ else: ++ ocf.logger.error( ++ f"[monitor_action]: local route '{local_route.route_name}' is not enabled" ++ ) ++ return ocf.OCF_ERR_GENERIC ++ else: ++ if local_route.is_enabled(): ++ ocf.logger.error( ++ f"[monitor_action]: local route '{local_route.route_name}' is enabled, but IP alias is not configured" ++ ) ++ return ocf.OCF_ERR_GENERIC ++ else: ++ ocf.logger.debug( ++ f"[monitor_action]: IP alias '{ip}' is not active and local route '{local_route.route_name}' is disabled" ++ ) ++ return ocf.OCF_NOT_RUNNING ++ ++ ++def validate_all_action( ++ ip="", ++ api_key="", ++ api_type="", ++ region="", ++ route_host_map="", ++ use_token_cache="", ++ monitor_api="", ++ device="", ++ proxy="", ++): ++ """Validate resource agent parameters. ++ ++ Verifies the provided resource agent options by attempting to instantiate route objects for both local and remote routes. ++ """ ++ resource_options = locals() ++ ++ ocf.logger.info("[validate_all_action]: validate local and remote routes") ++ _ = create_route_instance(resource_options) ++ _ = create_route_instance(resource_options, is_remote_route=True) ++ ++ return ocf.OCF_SUCCESS ++ ++ ++def main(): ++ """Instantiate the resource agent.""" ++ agent_description = textwrap.dedent("""\ ++ Resource Agent to move an IP address from one Power Virtual Server instance to another. ++ ++ Prerequisites: ++ 1. Red Hat Enterprise Linux 9.4 or higher ++ ++ 2. Two-node cluster ++ - Distributed across two PowerVS workspaces in separate data centers within the same region. ++ ++ 3. IBM Cloud API Key: ++ - Create a service API key with privileges for both workspaces. ++ - Save the key in a file and copy it to both cluster nodes using the same path and filename. ++ - Reference the key file path in the resource definition. ++ ++ For detailed guidance on high availability for SAP applications on PowerVS, visit: ++ https://cloud.ibm.com/docs/sap?topic=sap-ha-overview. ++ """) ++ ++ agent = ocf.Agent( ++ "powervs-move-ip", ++ shortdesc="Manages Power Virtual Server overlay IP routes.", ++ longdesc=agent_description, ++ version=1.00, ++ ) ++ ++ agent.add_parameter( ++ "ip", ++ shortdesc="IP address", ++ longdesc=( ++ "The virtual IP address is the destination address of a network route." ++ ), ++ 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( ++ "api_type", ++ shortdesc="API type", ++ longdesc="Connect to Power Virtual Server regional endpoints over a public or private network (public|private).", ++ content_type="string", ++ default="private", ++ required=True, ++ ) ++ 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( ++ "route_host_map", ++ shortdesc="Mapping of hostnames to IBM Cloud route CRNs", ++ longdesc=( ++ "Map the hostname of the Power Virtual Server instance to the route CRN of the overlay IP route. " ++ "Separate hostname and route CRN with a colon ':', separate different hostname and route CRN pairs with a semicolon ';'. " ++ "Example: hostname1:route-crn-of-instance1;hostname2:route-crn-of-instance2" ++ ), ++ content_type="string", ++ required=True, ++ ) ++ agent.add_parameter( ++ "use_token_cache", ++ shortdesc="Enable API token cache", ++ longdesc="Enable caching of the API access token in a local file to reduce authentication overhead. ", ++ content_type="string", ++ default="True", ++ required=False, ++ ) ++ agent.add_parameter( ++ "monitor_api", ++ shortdesc="Enhanced API Monitoring", ++ longdesc="Enable enhanced monitoring by using Power Cloud API calls to verify route configuration correctness. ", ++ content_type="string", ++ default="False", ++ required=False, ++ ) ++ agent.add_parameter( ++ "device", ++ shortdesc="Network adapter for the overlay IP address", ++ longdesc=( ++ "Network adapter for the overlay IP address. " ++ "The adapter must have the same name on all Power Virtual Server instances. " ++ "If the `device` parameter is not specified, the IP alias is assigned to the interface whose configured IP address matches the route's next hop address. " ++ ), ++ content_type="string", ++ default="", ++ required=False, ++ ) ++ agent.add_parameter( ++ "proxy", ++ shortdesc="Proxy", ++ longdesc=( ++ "Proxy server used to access IBM Cloud API endpoints. " ++ "The value must be a valid URL in the format 'http[s]://hostname:port'. " ++ ), ++ content_type="string", ++ default="", ++ required=False, ++ ) ++ agent.add_action("start", timeout=60, handler=start_action) ++ agent.add_action("stop", timeout=60, handler=stop_action) ++ agent.add_action( ++ "monitor", depth=0, timeout=60, interval=60, handler=monitor_action ++ ) ++ agent.add_action("validate-all", timeout=60, handler=validate_all_action) ++ agent.run() ++ ++ ++if __name__ == "__main__": ++ main() diff --git a/RHEL-114494-2-powervs-move-ip-add-iflabel-parameter.patch b/RHEL-114494-2-powervs-move-ip-add-iflabel-parameter.patch new file mode 100644 index 0000000..536ae0c --- /dev/null +++ b/RHEL-114494-2-powervs-move-ip-add-iflabel-parameter.patch @@ -0,0 +1,197 @@ +From a4e496e5e6d9abde1b071fa2dfa1c6e7ba899cf1 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Edmund=20H=C3=A4fele?= +Date: Thu, 30 Oct 2025 13:03:22 +0100 +Subject: [PATCH] Update powervs-move-ip + +- Add `iflabel` argument. +- Increase maximum number of retries for HTTP requests to four. +--- + heartbeat/powervs-move-ip.in | 66 +++++++++++++++++++++++++----------- + 1 file changed, 47 insertions(+), 19 deletions(-) + +diff --git a/heartbeat/powervs-move-ip.in b/heartbeat/powervs-move-ip.in +index d55979e52..e2250c998 100755 +--- a/heartbeat/powervs-move-ip.in ++++ b/heartbeat/powervs-move-ip.in +@@ -50,11 +50,13 @@ RESOURCE_OPTIONS = ( + "use_token_cache", + "monitor_api", + "device", ++ "iflabel", + "proxy", + ) + IP_CMD = "/usr/sbin/ip" ++IFLABEL_MAX_LEN = 15 # Maximum character limit for interface labels + REQUESTS_TIMEOUT = 5 # Timeout for requests calls +-HTTP_MAX_RETRIES = 3 # Maximum number of retries for HTTP requests ++HTTP_MAX_RETRIES = 4 # Maximum number of retries for HTTP requests + HTTP_BACKOFF_FACTOR = 0.3 # Sleep (factor * (2^number of previous retries)) secs + HTTP_STATUS_FORCE_RETRIES = (500, 502, 503, 504) # HTTP status codes to retry on + HTTP_RETRY_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "DELETE"}) +@@ -154,13 +156,13 @@ def ip_check_device(device): + return False + + +-def ip_alias_add(ip, device): ++def ip_alias_add(ip, device, label=None): + """Add an IP alias to the given device.""" + ip_cidr = f"{ip}/{CIDR_NETMASK}" + ocf.logger.debug( +- f"[ip_alias_add]: adding IP alias '{ip_cidr}' to interface '{device}'" ++ f"[ip_alias_add]: adding IP alias '{ip_cidr}' with label '{label}' to interface '{device}'" + ) +- _ = ip_address_add(ip_cidr, device) ++ _ = ip_address_add(ip_cidr, device, label) + + + def ip_alias_remove(ip): +@@ -522,6 +524,7 @@ class PowerCloudRoute(PowerCloudAPI): + region="", + route_host_map="", + device="", ++ iflabel="", + proxy="", + monitor_api="", + use_token_cache="", +@@ -543,6 +546,7 @@ class PowerCloudRoute(PowerCloudAPI): + self.route_info = self._get_route_info() + self.route_name = self.route_info["name"] + self.device = self._get_device_name(device) ++ self.iflabel = self._make_iflabel(iflabel) + + def _get_ip_info(self, ip): + """Validate the given IP address and return its standard form.""" +@@ -588,7 +592,7 @@ class PowerCloudRoute(PowerCloudAPI): + nodename = ( + hostname + if not self._is_remote_route +- else next((h for h in route_map if h != hostname), None) ++ else next((host for host in route_map if host != hostname), None) + ) + + if not nodename or nodename not in route_map: +@@ -646,6 +650,21 @@ class PowerCloudRoute(PowerCloudAPI): + ocf.OCF_ERR_CONFIGURED, + ) + ++ def _make_iflabel(self, label=None): ++ """Constructs an interface label in the format 'device:label' if both are provided.""" ++ if not label or self._is_remote_route: ++ return None ++ ++ iflabel = f"{self.device}:{label}" ++ ++ if len(iflabel) > IFLABEL_MAX_LEN: ++ raise PowerCloudRouteError( ++ f"_make_iflabel: interface label '{iflabel}' exceeds limit of {IFLABEL_MAX_LEN} characters", ++ ocf.OCF_ERR_CONFIGURED, ++ ) ++ ++ return iflabel ++ + def _set_route_enabled(self, enabled: bool): + """Enable or disable the PowerVS network route.""" + resource = f"/v1/routes/{self.route_id}" +@@ -706,6 +725,7 @@ def start_action( + use_token_cache="", + monitor_api="", + device="", ++ iflabel="", + proxy="", + ): + """Assign the service IP. +@@ -730,7 +750,7 @@ def start_action( + local_route = create_route_instance(resource_options) + + # Add IP alias +- ip_alias_add(ip, local_route.device) ++ ip_alias_add(ip, local_route.device, local_route.iflabel) + + # Enable local route + ocf.logger.debug(f"[start_action]: enabling local route '{local_route.route_name}'") +@@ -758,6 +778,7 @@ def stop_action( + use_token_cache="", + monitor_api="", + device="", ++ iflabel="", + proxy="", + ): + """Remove the service IP. +@@ -810,6 +831,7 @@ def monitor_action( + use_token_cache="", + monitor_api="", + device="", ++ iflabel="", + proxy="", + ): + """Monitor the service IP. +@@ -829,15 +851,11 @@ def monitor_action( + interface_name = ip_find_device(ip) + + if not use_extended_monitor: +- if interface_name: +- ocf.logger.debug( +- f"[monitor_action]: IP alias '{ip}' is active'" +- ) ++ if interface_name: ++ ocf.logger.debug(f"[monitor_action]: IP alias '{ip}' is active'") + return ocf.OCF_SUCCESS +- else: +- ocf.logger.debug( +- f"[monitor_action]: IP alias '{ip}' is not active" +- ) ++ else: ++ ocf.logger.debug(f"[monitor_action]: IP alias '{ip}' is not active") + return ocf.OCF_NOT_RUNNING + + remote_route = create_route_instance( +@@ -893,6 +911,7 @@ def validate_all_action( + use_token_cache="", + monitor_api="", + device="", ++ iflabel="", + proxy="", + ): + """Validate resource agent parameters. +@@ -914,12 +933,10 @@ def main(): + Resource Agent to move an IP address from one Power Virtual Server instance to another. + + Prerequisites: +- 1. Red Hat Enterprise Linux 9.4 or higher +- +- 2. Two-node cluster ++ 1. Two-node cluster + - Distributed across two PowerVS workspaces in separate data centers within the same region. + +- 3. IBM Cloud API Key: ++ 2. IBM Cloud API Key: + - Create a service API key with privileges for both workspaces. + - Save the key in a file and copy it to both cluster nodes using the same path and filename. + - Reference the key file path in the resource definition. +@@ -932,7 +949,7 @@ def main(): + "powervs-move-ip", + shortdesc="Manages Power Virtual Server overlay IP routes.", + longdesc=agent_description, +- version=1.00, ++ version=1.01, + ) + + agent.add_parameter( +@@ -1011,6 +1028,17 @@ def main(): + default="", + required=False, + ) ++ agent.add_parameter( ++ "iflabel", ++ shortdesc="Network interface label", ++ longdesc=( ++ "A custom suffix for the IP address label. " ++ "It is appended to the interface name in the format device:label. " ++ "The full label must not exceed 15 characters. " ++ ), ++ content_type="string", ++ required=False, ++ ) + agent.add_parameter( + "proxy", + shortdesc="Proxy", diff --git a/RHEL-116197-1-ocf-shellfuncs-add-ocf_promotion_score.patch b/RHEL-116197-1-ocf-shellfuncs-add-ocf_promotion_score.patch new file mode 100644 index 0000000..059d505 --- /dev/null +++ b/RHEL-116197-1-ocf-shellfuncs-add-ocf_promotion_score.patch @@ -0,0 +1,19 @@ +--- a/heartbeat/ocf-shellfuncs.in 2025-09-29 14:01:55.762931795 +0200 ++++ b/heartbeat/ocf-shellfuncs.in 2025-09-29 14:09:28.651731793 +0200 +@@ -1093,6 +1093,16 @@ + echo $1 + } + ++ocf_promotion_score() { ++ ocf_version_cmp "$OCF_RESKEY_crm_feature_set" "3.10.0" ++ res=$? ++ if [ $res -eq 2 ] || [ $res -eq 1 ] || ! have_binary "crm_master"; then ++ ${HA_SBIN_DIR}/crm_attribute -p ${OCF_RESOURCE_INSTANCE} $@ ++ else ++ ${HA_SBIN_DIR}/crm_master -l reboot $@ ++ fi ++} ++ + __ocf_set_defaults "$@" + + : ${OCF_TRACE_RA:=$OCF_RESKEY_trace_ra} diff --git a/RHEL-116197-2-portblock-add-promotable-support.patch b/RHEL-116197-2-portblock-add-promotable-support.patch new file mode 100644 index 0000000..0ae29e3 --- /dev/null +++ b/RHEL-116197-2-portblock-add-promotable-support.patch @@ -0,0 +1,362 @@ +--- a/heartbeat/portblock 2025-09-30 09:52:13.967530030 +0200 ++++ b/heartbeat/portblock 2025-09-30 09:52:49.018382542 +0200 +@@ -4,6 +4,7 @@ + # + # Author: Sun Jiang Dong (initial version) + # Philipp Reisner (per-IP filtering) ++# Sebastian Baszczyj (nftables code) + # + # License: GNU General Public License (GPL) + # +@@ -43,11 +44,15 @@ + ####################################################################### + CMD=`basename $0` + TICKLETCP=$HA_BIN/tickle_tcp ++TABLE="portblock" ++# Promotion scores ++SCORE_UNPROMOTED=5 ++SCORE_PROMOTED=10 + + usage() + { + cat <&2 +- usage: $CMD {start|stop|status|monitor|meta-data|validate-all} ++ usage: $CMD {start|stop|promote|demote|status|monitor|meta-data|validate-all} + + $CMD is used to temporarily block ports using iptables. + +@@ -86,8 +91,8 @@ + NOTE: iptables is Linux-specific. + + An additional feature in the portblock RA is the tickle ACK function +- enabled by specifying the tickle_dir parameter. The tickle ACK +- triggers the clients to faster reconnect their TCP connections to the ++ enabled by specifying the tickle_dir parameter. The tickle ACK ++ triggers the clients to faster reconnect their TCP connections to the + fail-overed server. + + Please note that this feature is often used for the floating IP fail- +@@ -95,7 +100,7 @@ + It doesn't support the cluster alias IP scenario. + + When using the tickle ACK function, in addition to the normal usage +- of portblock RA, the parameter tickle_dir must be specified in the ++ of portblock RA, the parameter tickle_dir must be specified in the + action=unblock instance of the portblock resources. + For example, you may stack resources like below: + portblock action=block +@@ -103,18 +108,18 @@ + portblock action=unblock tickle_dir=/tickle/state/dir + + If you want to tickle all the TCP connections which connected to _one_ +- floating IP but different ports, no matter how many portblock resources +- you have defined, you should enable tickles for _one_ portblock ++ floating IP but different ports, no matter how many portblock resources ++ you have defined, you should enable tickles for _one_ portblock + resource(action=unblock) only. +- +- The tickle_dir is a location which stores the established TCP +- connections. It can be a shared directory(which is cluster-visible to ++ ++ The tickle_dir is a location which stores the established TCP ++ connections. It can be a shared directory(which is cluster-visible to + all nodes) or a local directory. + If you use the shared directory, you needn't do any other things. + If you use the local directory, you must also specify the sync_script + paramater. We recommend you to use csync2 as the sync_script. +- For example, if you use the local directory /tmp/tickle as tickle_dir, +- you could setup the csync2 as the csync2 documentation says and ++ For example, if you use the local directory /tmp/tickle as tickle_dir, ++ you could setup the csync2 as the csync2 documentation says and + configure your /etc/csync2/csync2.cfg like: + group ticklegroup { + host node1; +@@ -137,15 +142,19 @@ + 1.0 + + +-Resource script for portblock. It is used to temporarily block ports ++Resource script for portblock. It is used to block ports + using iptables. In addition, it may allow for faster TCP reconnects + for clients on failover. Use that if there are long lived TCP + connections to an HA service. This feature is enabled by setting the + tickle_dir parameter and only in concert with action set to unblock. + Note that the tickle ACK function is new as of version 3.0.2 and + hasn't yet seen widespread use. ++ ++In Promotable mode, the promote action unblocks the port(s) on the Promoted node ++and blocks the port(s) on the Unpromoted node(s) when action=unblock, and vice versa ++when action=block. + +-Block and unblocks access to TCP and UDP ports ++Blocks and unblocks access to TCP and UDP ports + + + +@@ -167,6 +176,10 @@ + + + The action (block/unblock) to be done on the protocol::portno. ++ ++In Promotable mode it is the action for the promote action, ++and the opposite action will be used for the start and demote ++actions. + + action + +@@ -202,7 +215,7 @@ + + + +-The shared or local directory (_must_ be absolute path) which ++The shared or local directory (_must_ be absolute path) which + stores the established TCP connections. + + Tickle directory +@@ -236,6 +249,8 @@ + + + ++ ++ + + + +@@ -269,9 +284,9 @@ + # iptables 1.8.9 briefly broke the output format, returning the + # numeric protocol value instead of a string. Support both variants. + if [ "$1" = "tcp" ]; then +- local prot="(tcp|6)" ++ local prot="\(tcp\|6\)" + else +- local prot="(udp|17)" ++ local prot="\(udp\|17\)" + fi + echo "^DROP${w}${prot}${w}--${w}${src}${w}${dst}${w}multiport${w}${4}ports${w}${2}$" + } +@@ -281,7 +296,7 @@ + { + [ "$4" = "OUTPUT" ] && ds="s" || ds="d" + PAT=$(active_grep_pat "$1" "$2" "$3" "$ds") +- $IPTABLES $wait -n -L "$4" | grep -qE "$PAT" ++ $IPTABLES $wait -n -L "$4" | grep -q "$PAT" + } + + # netstat -tn and ss -Htn, split on whitespace and colon, +@@ -397,6 +412,17 @@ + rc=$OCF_NOT_RUNNING + ;; + esac ++ elif ocf_is_ms; then ++ case $5 in ++ block) ++ SayInactive $* ++ rc=$OCF_NOT_RUNNING ++ ;; ++ *) ++ SayActive $* ++ rc=$OCF_SUCCESS ++ ;; ++ esac + else + case $5 in + block) +@@ -493,18 +519,21 @@ + { + ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" start + case $5 in +- block) IptablesBLOCK "$@";; ++ block) IptablesBLOCK "$@" ++ rc=$? ++ ;; + unblock) + IptablesUNBLOCK "$@" + rc=$? + tickle_remote + #ignore run_tickle_tcp exit code! +- return $rc + ;; +- *) usage; return 1; ++ *) usage; return $OCF_ERR_CONFIGURED ; + esac + +- return $? ++ ocf_is_ms && ocf_promotion_score -v $SCORE_UNPROMOTED -N $nodename ++ ++ return $rc + } + + #IptablesStop {udp|tcp} portno,portno ip {in|out|both} {block|unblock} +@@ -512,17 +541,73 @@ + { + ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" stop + case $5 in +- block) IptablesUNBLOCK "$@";; ++ block) IptablesUNBLOCK "$@" ++ rc=$? ++ ;; + unblock) + save_tcp_connections + IptablesBLOCK "$@" ++ rc=$? + ;; +- *) usage; return 1;; ++ *) usage; return $OCF_ERR_CONFIGURED ;; + esac + ++ ocf_is_ms && ocf_promotion_score -D -N $nodename ++ ++ return $rc ++} ++ ++IptablesPromote() { ++ IptablesStatus "$@" ++ rc=$? ++ if [ $rc -eq $OCF_SUCCESS ] && [ $promotion_score -eq $SCORE_PROMOTED ]; then ++ ocf_log info "Promote: resource already promoted." ++ return $rc ++ elif [ $rc -ne $OCF_SUCCESS ] && [ $rc -ne $OCF_NOT_RUNNING ]; then ++ ocf_exit_reason "Promote: IptablesStatus failed with rc: $rc." ++ return $rc ++ fi ++ case $5 in ++ block) IptablesBLOCK "$@" ++ rc=$? ++ ;; ++ unblock) ++ IptablesUNBLOCK "$@" ++ rc=$? ++ tickle_remote ++ #ignore run_tickle_tcp exit code! ++ ;; ++ *) usage; return $OCF_ERR_CONFIGURED ; ++ esac ++ ocf_promotion_score -v $SCORE_PROMOTED -N $nodename + return $? + } + ++IptablesDemote() { ++ IptablesStatus "$@" ++ rc=$? ++ if [ $rc -eq $OCF_SUCCESS ] && [ $promotion_score -eq $SCORE_UNPROMOTED ]; then ++ ocf_log info "Demote: resource already demoted." ++ return $rc ++ elif [ $rc -ne $OCF_SUCCESS ] && [ $rc -ne $OCF_NOT_RUNNING ]; then ++ ocf_exit_reason "Demote: IptablesStatus failed with rc: $rc." ++ return $rc ++ fi ++ case $5 in ++ block) ++ save_tcp_connections ++ IptablesBLOCK "$@" ++ rc=$? ++ ;; ++ unblock) IptablesUNBLOCK "$@" ++ rc=$? ++ ;; ++ *) usage; return $OCF_ERR_CONFIGURED ;; ++ esac ++ ocf_promotion_score -v $SCORE_UNPROMOTED -N $nodename ++ return $rc ++} ++ + # + # Check if the port is valid, this function code is not decent, but works + # +@@ -558,17 +643,17 @@ + fi + if [ ! -d "$OCF_RESKEY_tickle_dir" ]; then + ocf_log err "The tickle dir doesn't exist!" +- exit $OCF_ERR_INSTALLED ++ exit $OCF_ERR_INSTALLED + fi + fi + + case $action in +- block|unblock) ++ block|unblock) + ;; +- *) ++ *) + ocf_log err "Invalid action $action!" + exit $OCF_ERR_CONFIGURED +- ;; ++ ;; + esac + + if ocf_is_true $reset_local_on_unblock_stop; then +@@ -591,7 +676,7 @@ + exit $OCF_ERR_ARGS + fi + +-case $1 in ++case $__OCF_ACTION in + meta-data) meta_data + exit $OCF_SUCCESS + ;; +@@ -605,12 +690,12 @@ + if [ -z "$OCF_RESKEY_protocol" ]; then + ocf_log err "Please set OCF_RESKEY_protocol" + exit $OCF_ERR_CONFIGURED +-fi ++fi + + if [ -z "$OCF_RESKEY_portno" ]; then + ocf_log err "Please set OCF_RESKEY_portno" + exit $OCF_ERR_CONFIGURED +-fi ++fi + + if [ -z "$OCF_RESKEY_action" ]; then + ocf_log err "Please set OCF_RESKEY_action" +@@ -632,6 +717,7 @@ + action=$OCF_RESKEY_action + ip=$OCF_RESKEY_ip + reset_local_on_unblock_stop=$OCF_RESKEY_reset_local_on_unblock_stop ++nodename=$(ocf_local_nodename) + + + # If "tickle" is enabled, we need to record the list of currently established +@@ -647,17 +733,35 @@ + fi + fi + +-case $1 in +- start) +- IptablesStart $protocol $portno $ip $direction $action ++if ocf_is_ms; then ++ promotion_score=$(ocf_promotion_score -G -N $nodename -q 2> /dev/null) ++ if { [ "$__OCF_ACTION" = "monitor" ] && [ "$promotion_score" = "$SCORE_UNPROMOTED" ]; } || [ "$__OCF_ACTION" = "demote" ] || [ "$__OCF_ACTION" = "start" ]; then ++ case $action in ++ block) action="unblock" ;; ++ unblock) action="block" ;; ++ esac ++ fi ++fi ++ ++case $__OCF_ACTION in ++ start) ++ IptablesStart "$protocol" "$portno" "$ip" "$direction" "$action" ++ ;; ++ ++ stop) ++ IptablesStop "$protocol" "$portno" "$ip" "$direction" "$action" ++ ;; ++ ++ promote) ++ IptablesPromote "$protocol" "$portno" "$ip" "$direction" "$action" + ;; + +- stop) +- IptablesStop $protocol $portno $ip $direction $action ++ demote) ++ IptablesDemote "$protocol" "$portno" "$ip" "$direction" "$action" + ;; + +- status|monitor) +- IptablesStatus $protocol $portno $ip $direction $action ++ status|monitor) ++ IptablesStatus "$protocol" "$portno" "$ip" "$direction" "$action" + ;; + + validate-all) diff --git a/RHEL-116197-3-portblock-fixes-add-method-and-status_check-parameters.patch b/RHEL-116197-3-portblock-fixes-add-method-and-status_check-parameters.patch new file mode 100644 index 0000000..39a7da3 --- /dev/null +++ b/RHEL-116197-3-portblock-fixes-add-method-and-status_check-parameters.patch @@ -0,0 +1,180 @@ +--- a/heartbeat/portblock 2025-10-21 09:27:41.753028260 +0200 ++++ b/heartbeat/portblock 2025-10-21 09:28:55.573855995 +0200 +@@ -28,6 +28,8 @@ + OCF_RESKEY_portno_default="" + OCF_RESKEY_direction_default="in" + OCF_RESKEY_action_default="" ++OCF_RESKEY_method_default="drop" ++OCF_RESKEY_status_check_default="rule" + OCF_RESKEY_ip_default="0.0.0.0/0" + OCF_RESKEY_reset_local_on_unblock_stop_default="false" + OCF_RESKEY_tickle_dir_default="" +@@ -37,6 +39,8 @@ + : ${OCF_RESKEY_portno=${OCF_RESKEY_portno_default}} + : ${OCF_RESKEY_direction=${OCF_RESKEY_direction_default}} + : ${OCF_RESKEY_action=${OCF_RESKEY_action_default}} ++: ${OCF_RESKEY_method=${OCF_RESKEY_method_default}} ++: ${OCF_RESKEY_status_check=${OCF_RESKEY_status_check_default}} + : ${OCF_RESKEY_ip=${OCF_RESKEY_ip_default}} + : ${OCF_RESKEY_reset_local_on_unblock_stop=${OCF_RESKEY_reset_local_on_unblock_stop_default}} + : ${OCF_RESKEY_tickle_dir=${OCF_RESKEY_tickle_dir_default}} +@@ -185,6 +189,26 @@ + + + ++ ++ ++Block method: ++drop: Use DROP rule. ++reject: Use REJECT rule w/conntrack to clear connections when blocking. ++ ++Block method ++ ++ ++ ++ ++ ++Status check: ++rule: Check rule. ++pseudo: Check pseudo status when rule is absent. ++ ++Status check ++ ++ ++ + + + If for some reason the long lived server side TCP sessions won't be cleaned up +@@ -253,6 +277,7 @@ + + + ++ + + + +@@ -288,7 +313,11 @@ + else + local prot="\(udp\|17\)" + fi +- echo "^DROP${w}${prot}${w}--${w}${src}${w}${dst}${w}multiport${w}${4}ports${w}${2}$" ++ if [ "$method" = "DROP" ]; then ++ echo "^DROP${w}${prot}${w}--${w}${src}${w}${dst}${w}multiport${w}${4}ports${w}${2}$" ++ else ++ echo "^REJECT${w}${prot}${w}--${w}${src}${w}${dst}${w}multiport${w}${4}ports${w}${2}${w}ctstate${w}NEW,RELATED,ESTABLISHED${w}reject-with${w}tcp-reset$" ++ fi + } + + #chain_isactive {udp|tcp} portno,portno ip chain +@@ -374,17 +403,17 @@ + + SayActive() + { +- ocf_log debug "$CMD DROP rule [$*] is running (OK)" ++ ocf_log debug "$CMD $method rule [$*] is running (OK)" + } + + SayConsideredActive() + { +- ocf_log debug "$CMD DROP rule [$*] considered to be running (OK)" ++ ocf_log debug "$CMD $method rule [$*] considered to be running (OK)" + } + + SayInactive() + { +- ocf_log debug "$CMD DROP rule [$*] is inactive" ++ ocf_log debug "$CMD $method rule [$*] is inactive" + } + + #IptablesStatus {udp|tcp} portno,portno ip {in|out|both} {block|unblock} +@@ -405,14 +434,18 @@ + case $5 in + block) + SayActive $* +- rc=$OCF_SUCCESS ++ if [ "$__OCF_ACTION" = "monitor" ] && [ "$promotion_score" = "$SCORE_PROMOTED" ]; then ++ rc=$OCF_RUNNING_MASTER ++ else ++ rc=$OCF_SUCCESS ++ fi + ;; + *) + SayInactive $* + rc=$OCF_NOT_RUNNING + ;; + esac +- elif ocf_is_ms; then ++ elif [ "$OCF_RESKEY_status_check" = "rule" ]; then + case $5 in + block) + SayInactive $* +@@ -420,7 +453,11 @@ + ;; + *) + SayActive $* +- rc=$OCF_SUCCESS ++ if [ "$__OCF_ACTION" = "monitor" ] && [ "$promotion_score" = "$SCORE_PROMOTED" ]; then ++ rc=$OCF_RUNNING_MASTER ++ else ++ rc=$OCF_SUCCESS ++ fi + ;; + esac + else +@@ -461,7 +498,11 @@ + : Chain already in desired state + else + [ "$chain" = "OUTPUT" ] && ds="s" || ds="d" +- $IPTABLES $wait "$op" "$chain" -p "$proto" -${ds} "$ip" -m multiport --${ds}ports "$ports" -j DROP ++ if [ "$method" = "DROP" ]; then ++ $IPTABLES $wait "$op" "$chain" -p "$proto" -${ds} "$ip" -m multiport --${ds}ports "$ports" -j DROP ++ else ++ $IPTABLES $wait "$op" "$chain" -p "$proto" -${ds} "$ip" -m multiport --${ds}ports "$ports" -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j REJECT --reject-with tcp-reset ++ fi + fi + } + +@@ -486,7 +527,11 @@ + $IPTABLES $wait -I OUTPUT -p "$1" -s "$3" -m multiport --sports "$2" -j REJECT --reject-with tcp-reset + tickle_local + fi +- $IPTABLES $wait -I INPUT -p "$1" -d "$3" -m multiport --dports "$2" -j DROP ++ if [ "$method" = "DROP" ]; then ++ $IPTABLES $wait -I INPUT -p "$1" -d "$3" -m multiport --dports "$2" -j DROP ++ else ++ $IPTABLES $wait -I INPUT -p "$1" -d "$3" -m multiport --dports "$2" -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j REJECT --reject-with tcp-reset ++ fi + rc_in=$? + if $try_reset ; then + $IPTABLES $wait -D OUTPUT -p "$1" -s "$3" -m multiport --sports "$2" -j REJECT --reject-with tcp-reset +@@ -718,6 +763,13 @@ + ip=$OCF_RESKEY_ip + reset_local_on_unblock_stop=$OCF_RESKEY_reset_local_on_unblock_stop + nodename=$(ocf_local_nodename) ++case "$OCF_RESKEY_method" in ++ drop) method="DROP" ;; ++ reject) method="REJECT" ;; ++ *) ocf_log err "method: $OCF_RESKEY_method not supported" ++ exit $OCF_ERR_CONFIGURED ++ ;; ++esac + + + # If "tickle" is enabled, we need to record the list of currently established +@@ -743,6 +795,8 @@ + fi + fi + ++IptablesValidateAll ++ + case $__OCF_ACTION in + start) + IptablesStart "$protocol" "$portno" "$ip" "$direction" "$action" +@@ -765,7 +819,6 @@ + ;; + + validate-all) +- IptablesValidateAll + ;; + + *) usage diff --git a/RHEL-116197-4-portblock-check-inverse-action.patch b/RHEL-116197-4-portblock-check-inverse-action.patch new file mode 100644 index 0000000..bbba366 --- /dev/null +++ b/RHEL-116197-4-portblock-check-inverse-action.patch @@ -0,0 +1,156 @@ +--- a/heartbeat/portblock 2026-02-27 08:43:50.813925268 +0100 ++++ b/heartbeat/portblock 2026-02-27 08:44:40.481824601 +0100 +@@ -29,12 +29,17 @@ + OCF_RESKEY_direction_default="in" + OCF_RESKEY_action_default="" + OCF_RESKEY_method_default="drop" +-OCF_RESKEY_status_check_default="rule" + OCF_RESKEY_ip_default="0.0.0.0/0" + OCF_RESKEY_reset_local_on_unblock_stop_default="false" + OCF_RESKEY_tickle_dir_default="" + OCF_RESKEY_sync_script_default="" + ++if ocf_is_ms; then ++ OCF_RESKEY_status_check_default="rule" ++else ++ OCF_RESKEY_status_check_default="pseudo" ++fi ++ + : ${OCF_RESKEY_protocol=${OCF_RESKEY_protocol_default}} + : ${OCF_RESKEY_portno=${OCF_RESKEY_portno_default}} + : ${OCF_RESKEY_direction=${OCF_RESKEY_direction_default}} +@@ -401,6 +406,10 @@ + done + } + ++# A long time ago, these messages needed to go to stdout, ++# "running" / "OK" being the trigger string ++# for heartbeat in haresources mode. ++# Now they are still useful for debugging. + SayActive() + { + ocf_log debug "$CMD $method rule [$*] is running (OK)" +@@ -416,6 +425,11 @@ + ocf_log debug "$CMD $method rule [$*] is inactive" + } + ++SayConsideredInactive() ++{ ++ ocf_log debug "$CMD $method rule [$*] considered to be inactive" ++} ++ + #IptablesStatus {udp|tcp} portno,portno ip {in|out|both} {block|unblock} + IptablesStatus() { + local rc +@@ -441,8 +455,17 @@ + fi + ;; + *) +- SayInactive $* +- rc=$OCF_NOT_RUNNING ++ if [ "$OCF_RESKEY_status_check" != "rule" ] \ ++ && test -e "$state_file" && test "$inverse_state_file" -nt "$state_file"; then ++ # rule present, action=unblock, unblock statefile present, ++ # block state file more recent. ++ # apparently an unusual setup: unblock first, block later ++ SayConsideredActive $* ++ rc=$OCF_SUCCESS ++ else ++ SayInactive $* ++ rc=$OCF_NOT_RUNNING ++ fi + ;; + esac + elif [ "$OCF_RESKEY_status_check" = "rule" ]; then +@@ -454,6 +477,7 @@ + *) + SayActive $* + if [ "$__OCF_ACTION" = "monitor" ] && [ "$promotion_score" = "$SCORE_PROMOTED" ]; then ++ save_tcp_connections + rc=$OCF_RUNNING_MASTER + else + rc=$OCF_SUCCESS +@@ -463,7 +487,10 @@ + else + case $5 in + block) +- if ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" status; then ++ if test -e "$state_file" && test "$inverse_state_file" -nt "$state_file"; then ++ # rule NOT present, action=block, block state file present, ++ # unblock state file more recent. ++ # expected setup: block first, unblock later + SayConsideredActive $* + rc=$OCF_SUCCESS + else +@@ -472,13 +499,15 @@ + fi + ;; + *) +- if ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" status; then ++ if test -e "$state_file" ; then ++ # rule NOT present, action=unblock, unblock state file present + SayActive $* +- #This is only run on real monitor events. ++ # This is only run on real monitor events (state file present). + save_tcp_connections + rc=$OCF_SUCCESS + else +- SayInactive $* ++ # rule NOT present, action=unblock, unblock state file NOT present ++ SayConsideredInactive $* + rc=$OCF_NOT_RUNNING + fi + ;; +@@ -562,7 +591,7 @@ + #IptablesStart {udp|tcp} portno,portno ip {in|out|both} {block|unblock} + IptablesStart() + { +- ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" start ++ ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" start "$state_file" + case $5 in + block) IptablesBLOCK "$@" + rc=$? +@@ -584,7 +613,8 @@ + #IptablesStop {udp|tcp} portno,portno ip {in|out|both} {block|unblock} + IptablesStop() + { +- ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" stop ++ ha_pseudo_resource "${OCF_RESOURCE_INSTANCE}" stop "$state_file" ++ + case $5 in + block) IptablesUNBLOCK "$@" + rc=$? +@@ -797,6 +827,33 @@ + + IptablesValidateAll + ++# State file name for ha_pseudo_resource ++# ++# The expected usage of this agent is to pair a "block" with an "unblock", ++# and order startup and configuration of some service between these. ++# ++# The established idiom is to have two separate instances with inverse actions. ++# To "reliably" report the status of "block" during a monitor action, ++# it is not sufficient to check the existence of the blocking rule. ++# ++# It is also insufficient to rely on the pseudo resource state file ++# of this instance only. ++# ++# To know our actual expectation, we need to check the state file of the ++# "inverse" instance as well. ++# ++# Because we don't know the OCF_RESOURCE_INSTANCE value of the other instance, ++# we override the state file name for both instances to something derived from ++# our parameters. ++# ++# This should give use the same "global state" view as the "promotion score" ++# does for the promotable clone variant of this agent. ++# ++[ "$action" = block ] && inverse_action=unblock || inverse_action=block ++state_file_base=$(echo "portblock_${protocol}_${portno}_${ip}_${direction}" | tr -c '0-9a-zA-Z._' _) ++state_file=${HA_RSCTMP}/${state_file_base}_${action} ++inverse_state_file=${HA_RSCTMP}/${state_file_base}_${inverse_action} ++ + case $__OCF_ACTION in + start) + IptablesStart "$protocol" "$portno" "$ip" "$direction" "$action" diff --git a/RHEL-112443-1-nginx-fix-validate-warnings.patch b/RHEL-126121-1-nginx-fix-validate-warnings.patch similarity index 100% rename from RHEL-112443-1-nginx-fix-validate-warnings.patch rename to RHEL-126121-1-nginx-fix-validate-warnings.patch diff --git a/RHEL-112443-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch b/RHEL-126121-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch similarity index 100% rename from RHEL-112443-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch rename to RHEL-126121-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch diff --git a/RHEL-143528-powervs-move-ip-powervs-subnet-fix-error-logging.patch b/RHEL-143528-powervs-move-ip-powervs-subnet-fix-error-logging.patch new file mode 100644 index 0000000..9558783 --- /dev/null +++ b/RHEL-143528-powervs-move-ip-powervs-subnet-fix-error-logging.patch @@ -0,0 +1,54 @@ +From 8f5c5a2a472ab404b6fd15ff492e72904dc8ac20 Mon Sep 17 00:00:00 2001 +From: Oyvind Albrigtsen +Date: Thu, 22 Jan 2026 07:37:40 +0100 +Subject: [PATCH] powervs-move-ip/powervs-subnet: fix error logging + +--- + heartbeat/powervs-move-ip.in | 4 ++-- + heartbeat/powervs-subnet.in | 10 ++++++---- + 2 files changed, 8 insertions(+), 6 deletions(-) + +diff --git a/heartbeat/powervs-move-ip.in b/heartbeat/powervs-move-ip.in +index e2250c998..0eea89f1d 100755 +--- a/heartbeat/powervs-move-ip.in ++++ b/heartbeat/powervs-move-ip.in +@@ -310,9 +310,9 @@ class PowerCloudTokenManager: + return json.load(f) + finally: + fcntl.flock(f, fcntl.LOCK_UN) +- except (json.JSONDecodeError, FileNotFoundError, PermissionError): ++ except (json.JSONDecodeError, FileNotFoundError, PermissionError) as e: + ocf.logger.warning( +- "[PowerCloudTokenManager] _read_cache: failed to read token cache read due to missing file or malformed JSON." ++ f"[PowerCloudTokenManager] _read_cache: failed to read token cache read due to missing file or malformed JSON: '{e}'" + ) + return {} + +diff --git a/heartbeat/powervs-subnet.in b/heartbeat/powervs-subnet.in +index 062b1235e..b8f3864e9 100755 +--- a/heartbeat/powervs-subnet.in ++++ b/heartbeat/powervs-subnet.in +@@ -837,8 +837,9 @@ def start_action( + if rc != ocf.OCF_SUCCESS: + return rc + +- if monitor_action(**res_options) != ocf.OCF_SUCCESS: +- raise PowerCloudAPIError(f"start_action: start subnet: {ws.subnet_name} failed") ++ rc = monitor_action(**res_options) ++ if rc != ocf.OCF_SUCCESS: ++ raise PowerCloudAPIError(f"start_action: start subnet: {ws.subnet_name} failed", rc) + + ocf.logger.info( + f"start_action: finished, added connection {conn_name} for subnet {ws.subnet_name}" +@@ -872,8 +873,9 @@ def stop_action( + + ws.subnet_remove() + +- if monitor_action(**res_options) != ocf.OCF_NOT_RUNNING: +- raise PowerCloudAPIError(f"stop_action: stop subnet {ws.subnet_name} failed") ++ rc = monitor_action(**res_options) ++ if rc != ocf.OCF_NOT_RUNNING: ++ raise PowerCloudAPIError(f"stop_action: stop subnet {ws.subnet_name} failed", rc) + + ocf.logger.info( + f"stop_action: finished, deleted connection for subnet {ws.subnet_name}" diff --git a/RHEL-81238-powervs-subnet-wait-for-IP.patch b/RHEL-81238-powervs-subnet-wait-for-IP.patch new file mode 100644 index 0000000..60f5cea --- /dev/null +++ b/RHEL-81238-powervs-subnet-wait-for-IP.patch @@ -0,0 +1,43 @@ +From 0b4bf9c23eb60455da6c6a16c1df19282ab2a8b5 Mon Sep 17 00:00:00 2001 +From: Oyvind Albrigtsen +Date: Fri, 9 Jan 2026 12:56:14 +0100 +Subject: [PATCH] powervs-subnet: wait until IP is activated before running + monitor-check + +--- + heartbeat/powervs-subnet.in | 15 +++++++++++++-- + 1 file changed, 13 insertions(+), 2 deletions(-) + +diff --git a/heartbeat/powervs-subnet.in b/heartbeat/powervs-subnet.in +index 84e86c0c4..062b1235e 100755 +--- a/heartbeat/powervs-subnet.in ++++ b/heartbeat/powervs-subnet.in +@@ -243,7 +243,16 @@ class nmcli: + + @staticmethod + def up(name, **kwargs): +- return nmcli._nmcli_cmd("connection", "up", name, **kwargs) ++ nmcli._nmcli_cmd("connection", "up", name, **kwargs) ++ ++ for i in range(1, 10): ++ time.sleep(1) ++ status = nmcli._nmcli_cmd("connection", "show", name, **kwargs) ++ if len(status.get("IP4.ADDRESS[1]", "")) > 0: ++ return ocf.OCF_SUCCESS ++ ocf.logger.warning(f"nmcli.connection.up: check {i} of 10: IP not yet available.") ++ ++ return ocf.OCF_ERR_GENERIC + + @staticmethod + def find(match_key, match_value): +@@ -824,7 +833,9 @@ def start_action( + 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) ++ rc = nmcli.connection.up(conn_name) ++ if rc != ocf.OCF_SUCCESS: ++ return rc + + if monitor_action(**res_options) != ocf.OCF_SUCCESS: + raise PowerCloudAPIError(f"start_action: start subnet: {ws.subnet_name} failed") diff --git a/resource-agents.spec b/resource-agents.spec index 6c96047..b084a5b 100644 --- a/resource-agents.spec +++ b/resource-agents.spec @@ -45,7 +45,7 @@ Name: resource-agents Summary: Open Source HA Reusable Cluster Resource Scripts Version: 4.16.0 -Release: 22%{?rcver:%{rcver}}%{?numcomm:.%{numcomm}}%{?alphatag:.%{alphatag}}%{?dirty:.%{dirty}}%{?dist}.7 +Release: 22%{?rcver:%{rcver}}%{?numcomm:.%{numcomm}}%{?alphatag:.%{alphatag}}%{?dirty:.%{dirty}}%{?dist}.9 License: GPL-2.0-or-later AND LGPL-2.1-or-later URL: https://github.com/ClusterLabs/resource-agents Source0: %{upstream_prefix}-%{upstream_version}.tar.gz @@ -77,12 +77,20 @@ Patch24: RHEL-85014-IPaddr2-add-link-status-DOWN-LOWERLAYERDOWN-check.patch Patch25: RHEL-99743-Filesystem-remove-validate-all-fstype-check.patch Patch26: RHEL-97216-Filesystem-fix-issue-with-Vormetric-mounts.patch Patch27: RHEL-102728-ocf-shellfuncs-remove-extra-sleep-from-curl_retry.patch -Patch28: RHEL-88431-1-podman-etcd-new-ra.patch -Patch29: RHEL-88431-2-podman-etcd-remove-unused-actions-from-metadata.patch -Patch30: RHEL-88431-3-podman-etcd-fix-listen-peer-urls-binding.patch -Patch31: RHEL-113104-podman-etcd-add-oom-parameter.patch -Patch51: RHEL-112443-1-nginx-fix-validate-warnings.patch -Patch56: RHEL-112443-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch +Patch28: RHEL-113751-1-podman-etcd-new-ra.patch +Patch29: RHEL-113751-2-podman-etcd-remove-unused-actions-from-metadata.patch +Patch30: RHEL-113751-3-podman-etcd-fix-listen-peer-urls-binding.patch +Patch31: RHEL-113106-podman-etcd-add-oom-parameter.patch +Patch32: RHEL-126121-1-nginx-fix-validate-warnings.patch +Patch33: RHEL-126121-2-nginx-restore-selinux-context-for-pid-file-during-validate-all-action.patch +Patch34: RHEL-114494-1-powervs-move-ip-new-ra.patch +Patch35: RHEL-114494-2-powervs-move-ip-add-iflabel-parameter.patch +Patch36: RHEL-81238-powervs-subnet-wait-for-IP.patch +Patch37: RHEL-143528-powervs-move-ip-powervs-subnet-fix-error-logging.patch +Patch38: RHEL-116197-1-ocf-shellfuncs-add-ocf_promotion_score.patch +Patch39: RHEL-116197-2-portblock-add-promotable-support.patch +Patch40: RHEL-116197-3-portblock-fixes-add-method-and-status_check-parameters.patch +Patch41: RHEL-116197-4-portblock-check-inverse-action.patch # bundled ha-cloud-support libs Patch500: ha-cloud-support-aliyun.patch @@ -273,8 +281,16 @@ exit 1 %patch -p1 -P 29 %patch -p1 -P 30 %patch -p1 -P 31 -%patch -p1 -P 51 -%patch -p1 -P 56 +%patch -p1 -P 32 +%patch -p1 -P 33 +%patch -p1 -P 34 +%patch -p1 -P 35 +%patch -p1 -P 36 +%patch -p1 -P 37 +%patch -p1 -P 38 -F1 +%patch -p1 -P 39 +%patch -p1 -P 40 +%patch -p1 -P 41 # bundled ha-cloud-support libs %patch -p1 -P 500 @@ -605,18 +621,31 @@ rm -rf %{buildroot}/usr/share/doc/resource-agents %{_usr}/lib/ocf/lib/heartbeat/OCF_*.pm %changelog -* Fri Dec 19 2025 Alan Steinberg - 4.16.0-22.7 +* Fri Feb 27 2026 Oyvind Albrigtsen - 4.16.0-22.9 +- portblock: add promotable support, and method and status_check + parameters + + Resolves: RHEL-116197 + +* Mon Jan 26 2026 Oyvind Albrigtsen - 4.16.0-22.8 +- powervs-move-ip: new resource agent +- powervs-subnet: new resource agent +- powervs-move-ip/powervs-subnet: fix error logging + + Resolves: RHEL-114494, RHEL-81238, RHEL-143528 + +* Fri Dec 5 2025 Oyvind Albrigtsen - 4.16.0-22.7 - nginx: fix validate warnings, and restore SELinux context for pid-file during validate-all action - Resolves: RHEL-112443 + Resolves: RHEL-126121 -* Wed Nov 19 2025 Alan Steinberg - 4.16.0-22.1 +* Tue Sep 9 2025 Oyvind Albrigtsen - 4.16.0-22.2 - podman-etcd: new resource agent - podman-etcd: add oom parameter to be able to tune the Out-Of-Memory (OOM) score for etcd containers - Resolves: RHEL-88431, RHEL-113104 + Resolves: RHEL-113751, RHEL-113106 * Tue Jul 15 2025 Oyvind Albrigtsen - 4.16.0-22 - ocf-shellfuncs/AWS agents: dont sleep after the final try in