From e0af8f800cae6d9d42c7fc5d890e81147957a10b Mon Sep 17 00:00:00 2001 From: ebuchbut Date: Sun, 14 Jun 2026 11:42:07 +0300 Subject: [PATCH 1/2] WBL-396559 Add WCM event integration example with Remedy --- .gitattributes | 1 + .../README.md | 188 ++++++++++++++++++ .../handle_alert.py | 188 ++++++++++++++++++ .../itsm_config.json | 20 ++ .../remedy_client.py | 87 ++++++++ .../requirements.txt | 2 + .../script.sh | 8 + .../settings.json | 10 + 8 files changed, 504 insertions(+) create mode 100644 .gitattributes create mode 100644 saas-control-m/302-wcm-event-integration-remedy/README.md create mode 100644 saas-control-m/302-wcm-event-integration-remedy/handle_alert.py create mode 100644 saas-control-m/302-wcm-event-integration-remedy/itsm_config.json create mode 100644 saas-control-m/302-wcm-event-integration-remedy/remedy_client.py create mode 100644 saas-control-m/302-wcm-event-integration-remedy/requirements.txt create mode 100644 saas-control-m/302-wcm-event-integration-remedy/script.sh create mode 100644 saas-control-m/302-wcm-event-integration-remedy/settings.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b68e114 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +"*.sh text eol=lf" diff --git a/saas-control-m/302-wcm-event-integration-remedy/README.md b/saas-control-m/302-wcm-event-integration-remedy/README.md new file mode 100644 index 0000000..ce5c883 --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/README.md @@ -0,0 +1,188 @@ +# Control-M WCM Integration → BMC Remedy + +This example connects **Control-M Workspace Change Management (WCM) Integration alerts** to **BMC Remedy ITSM**. When a user submits a workspace for approval, the integration automatically opens a Remedy change request and writes the ticket ID back to the workspace in Control-M. + +## How it works + +```mermaid +sequenceDiagram + participant WCM as WCM Integration + participant Script as script.sh + participant Handler as handle_alert.py + participant CTM as Control-M API + participant Remedy as Remedy REST API + + WCM->>Script: Alert (workspace state change) + Script->>Handler: Forward alert fields as arguments + Handler->>Handler: Check transition RequesterWorks → Submitted + alt Invalid workspace name + Handler->>CTM: Return workspace to requester + else Valid workspace name + Handler->>Remedy: Create change request (JWT auth) + Remedy-->>Handler: Change request ID + Handler->>CTM: Update workspace changeId + end +``` + +### Trigger condition + +`handle_alert.py` acts only when all of the following are true: + +| Field | Expected value | +|---|---| +| `oldState` | `RequesterWorks` | +| `newState` | `Submitted` | +| `changeId` | empty or `N/A` | + +If the workspace name does **not** start with `Workspace`, the handler returns the workspace to the requester with a validation message instead of opening a ticket. + +## Project layout + +| File | Purpose | +|---|---| +| `script.sh` | Entry point configured in WCM Integration; logs the alert and calls the Python handler | +| `handle_alert.py` | Parses the alert, calls Control-M and Remedy APIs | +| `remedy_client.py` | Remedy JWT authentication and change-request creation | +| `settings.json` | Control-M Automation API endpoint and API key | +| `itsm_config.json` | Remedy connection settings and default ticket field values | +| `requirements.txt` | Python third-party dependencies | + +Logs are written to `logs/handle_alert.log`. Raw alert payloads are appended to `log.out` by `script.sh`. + +## Prerequisites + +- Control-M SaaS (or compatible environment) with **WCM Integration** enabled +- A Control-M **Automation API** key with permissions to list/update workspaces +- BMC Remedy with the **REST API** enabled and a service account that can create change requests +- Python **3.9+** and `pip` +- A Linux/Unix shell to run `script.sh` (or WSL on Windows) + +## Installation + +1. Copy this folder to the machine that will receive WCM Integration alerts. + +2. Install Python dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Make the shell script executable: + +```bash +chmod +x script.sh +``` + +## Configuration + +### Control-M — `settings.json` + +```json +{ + "endpoint": "https:///automation-api", + "apiKey": "" +} +``` + +| Key | Description | +|---|---| +| `endpoint` | Control-M Automation API base URL (no trailing slash) | +| `apiKey` | API key used for workspace list/update calls | + +### Remedy — `itsm_config.json` + +```json +{ + "remedy_base_url": "https://:/api/arsys/v1", + "remedy_form": "CHG:ChangeInterface_Create", + "username": "", + "password": "", + "verify_ssl": true, + "ticket_defaults": { + "First Name": "App", + "Last Name": "Admin", + "Impact": "4-Minor/Localized", + "Urgency": "4-Low", + "Location Company": "Calbro Services", + "Detailed Description": "Automatic ticket from Control-M WCM Integration.", + "ASCPY": "Calbro Services", + "ASORG": "IT Support", + "ASGRP": "Backoffice Support", + "ASCHG": "Mary Mann", + "ASLOGID": "Mary" + } +} +``` + +| Key | Description | +|---|---| +| `remedy_base_url` | Remedy AR System REST API base URL | +| `remedy_form` | Form used to create changes (default: `CHG:ChangeInterface_Create`) | +| `username` / `password` | Remedy credentials for JWT login | +| `verify_ssl` | Set to `false` only for lab environments with self-signed certificates | +| `ticket_defaults` | Default field values sent when creating the change request | + +Adjust `ticket_defaults` to match your Remedy environment (company, support group, assignee, etc.). + +> **Security:** Do not commit real credentials. Restrict file permissions on `itsm_config.json` and `settings.json`. + +## Connect WCM Integration to this handler + +In the Control-M **WCM Integration** alert configuration, point the alert action to `script.sh`: + +1. Open WCM Integration settings in Control-M. +2. Configure an alert for workspace state changes (or use the existing WCMOS/WCM alert template). +3. Set the **handler script** to the full path of `script.sh`, for example: + +``` +/opt/wcm-listener/script.sh +``` + +4. Ensure the Control-M integration service can execute the script and that `python3` is on the `PATH` used by that service. + +When an alert fires, WCM Integration invokes the script with the alert fields as arguments in `key: value` form, for example: + +``` +changeId: N/A ctmRequestId: 12345 name: Workspace-MyJob oldState: RequesterWorks newState: Submitted endUser: jdoe +``` + +`script.sh` forwards those arguments unchanged to `handle_alert.py`. + +## Manual test + +You can simulate an alert without waiting for WCM Integration: + +```bash +./script.sh \ + "changeId: N/A" \ + "ctmRequestId: 12345" \ + "name: Workspace-TestJob" \ + "oldState: RequesterWorks" \ + "newState: Submitted" \ + "endUser: jdoe" +``` + +Check `logs/handle_alert.log` for processing details and confirm that: + +1. A change request was created in Remedy. +2. The workspace `changeId` was updated in Control-M. + +Alerts that do not match the trigger condition are logged and ignored: + +```bash +./script.sh "oldState: Draft" "newState: RequesterWorks" "name: Workspace-TestJob" +``` + +## Troubleshooting + +| Symptom | Things to check | +|---|---| +| Script not invoked | WCM Integration handler path, script execute permission, service account | +| `Control-M endpoint/apiKey missing` | `settings.json` values and file location next to `handle_alert.py` | +| `ITSM config not found` | `itsm_config.json` exists in the same folder as `remedy_client.py` | +| Remedy auth failure | Username/password, Remedy REST API URL, SSL settings | +| Remedy create failure | `ticket_defaults` field names/values for your Remedy form | +| Workspace not updated | API key permissions, workspace name match, Control-M endpoint reachability | +| Workspace returned instead of ticket opened | Workspace name must start with `Workspace` | + +Raw alert payloads are always appended to `log.out` for debugging. diff --git a/saas-control-m/302-wcm-event-integration-remedy/handle_alert.py b/saas-control-m/302-wcm-event-integration-remedy/handle_alert.py new file mode 100644 index 0000000..27e91fe --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/handle_alert.py @@ -0,0 +1,188 @@ +""" +Alert handler – evaluates Control-M workspace state transitions and opens +ITSM change requests when appropriate. + +Invoked by script.sh with key-value pairs: + changeId: ctmRequestId: name: newState: oldState: ... +""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +import requests + +from remedy_client import create_change_request, load_itsm_config + +CTM_CONFIG_FILE = Path(__file__).parent / "settings.json" +LOG_DIR = Path(__file__).parent / "logs" +LOG_DIR.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.FileHandler(LOG_DIR / "handle_alert.log"), + logging.StreamHandler(), + ], +) +logger = logging.getLogger("handle_alert") + + +def parse_args(argv: list[str]) -> dict[str, str]: + """Parse ``key: value`` arguments into a dict. Each arg is ``"key: value"``.""" + pairs: dict[str, str] = {} + for arg in argv: + key, _, value = arg.partition(": ") + pairs[key.strip()] = value.strip() + return pairs + + +# --------------------------------------------------------------------------- +# Control-M workspace helpers +# --------------------------------------------------------------------------- +def load_ctm_config() -> dict: + if not CTM_CONFIG_FILE.exists(): + logger.error("Control-M config not found: %s", CTM_CONFIG_FILE) + return {} + with open(CTM_CONFIG_FILE, encoding="utf-8") as f: + return json.load(f) + + +def get_workspaces(endpoint: str, api_key: str) -> list[dict]: + """GET /build/workspaces – return the list of workspaces.""" + url = f"{endpoint}/build/workspaces" + headers = {"x-api-key": api_key} + logger.info("Listing workspaces: GET %s", url) + resp = requests.get(url, headers=headers, timeout=30) + if not resp.ok: + logger.error("Failed to list workspaces [%d]: %s", resp.status_code, resp.text) + resp.raise_for_status() + data = resp.json() + return data.get("workspaces", []) + + +def find_workspace_id(workspaces: list[dict], workspace_name: str) -> str | None: + """Find the workspace ID matching the given name.""" + for ws in workspaces: + if ws.get("name") == workspace_name: + return str(ws.get("id")) + return None + + +def update_workspace_change_id(endpoint: str, api_key: str, workspace_id: str, change_id: str): + """PUT /build/workspace/{workspaceId} – update the changeId.""" + url = f"{endpoint}/build/workspace/{workspace_id}" + headers = {"x-api-key": api_key, "Content-Type": "application/json"} + params = {"takeOwnership": "true"} + payload = {"changeId": change_id} + logger.info("Updating workspace '%s' with changeId='%s'", workspace_id, change_id) + resp = requests.put(url, headers=headers, json=payload, params=params, timeout=30) + if not resp.ok: + logger.error("Failed to update workspace [%d]: %s", resp.status_code, resp.text) + resp.raise_for_status() + logger.info("Workspace '%s' updated successfully", workspace_id) + + +def return_workspace(endpoint: str, api_key: str, workspace_id: str, reason: str): + """POST /build/workspace/{workspaceId}/return – return workspace to user.""" + url = f"{endpoint}/build/workspace/{workspace_id}/return" + headers = {"x-api-key": api_key, "Content-Type": "application/json"} + payload = {"returnReason": reason} + logger.info("Returning workspace '%s' with reason: %s", workspace_id, reason) + resp = requests.post(url, headers=headers, json=payload, timeout=30) + if not resp.ok: + logger.error("Failed to return workspace [%d]: %s", resp.status_code, resp.text) + resp.raise_for_status() + logger.info("Workspace '%s' returned successfully", workspace_id) + + +def get_ctm_context() -> tuple[str, str] | None: + """Load Control-M endpoint and apiKey, return (endpoint, api_key) or None.""" + ctm_config = load_ctm_config() + endpoint = ctm_config.get("endpoint", "") + api_key = ctm_config.get("apiKey", "") + if not endpoint or not api_key: + logger.error("Control-M endpoint/apiKey missing in settings.json") + return None + return endpoint, api_key + + +def main(): + logger.info("sys.argv = %s", sys.argv) + alert = parse_args(sys.argv[1:]) + logger.info("Parsed alert = %s", alert) + old_state = alert.get("oldState", "") + new_state = alert.get("newState", "") + workspace_name = alert.get("name", "") + + logger.info( + "Alert received – workspace='%s' oldState='%s' newState='%s'", + workspace_name, old_state, new_state, + ) + + change_id = alert.get("changeId", "").strip() + + if not ( + old_state == "RequesterWorks" + and new_state == "Submitted" + and change_id in ("", "N/A") + ): + logger.info( + "No action required for transition '%s' -> '%s'", old_state, new_state + ) + return + + logger.info("State transition RequesterWorks -> Submitted detected") + + ctx = get_ctm_context() + if not ctx: + return + endpoint, api_key = ctx + + if not workspace_name.startswith("Workspace"): + reason = "The workspace name must start with 'Workspace'" + logger.info("Workspace name '%s' invalid – returning workspace", workspace_name) + try: + workspaces = get_workspaces(endpoint, api_key) + ws_id = find_workspace_id(workspaces, workspace_name) + if ws_id: + return_workspace(endpoint, api_key, ws_id, reason) + else: + logger.error("Workspace '%s' not found in Control-M", workspace_name) + except requests.HTTPError as exc: + logger.error("Failed to return workspace: %s", exc) + except Exception: + logger.exception("Unexpected error returning workspace") + return + + logger.info("Opening change request for workspace '%s'", workspace_name) + itsm_config = load_itsm_config() + ticket_id = None + try: + ticket_id = create_change_request(itsm_config, alert) + except requests.HTTPError as exc: + logger.error("Failed to create Remedy change request: %s", exc) + except Exception: + logger.exception("Unexpected error creating Remedy change request") + + if ticket_id: + try: + workspaces = get_workspaces(endpoint, api_key) + ws_id = find_workspace_id(workspaces, workspace_name) + if ws_id: + update_workspace_change_id(endpoint, api_key, ws_id, ticket_id) + else: + logger.error("Workspace '%s' not found in Control-M", workspace_name) + except requests.HTTPError as exc: + logger.error("Failed to update workspace changeId: %s", exc) + except Exception: + logger.exception("Unexpected error updating workspace changeId") + + +if __name__ == "__main__": + main() diff --git a/saas-control-m/302-wcm-event-integration-remedy/itsm_config.json b/saas-control-m/302-wcm-event-integration-remedy/itsm_config.json new file mode 100644 index 0000000..17ec5e9 --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/itsm_config.json @@ -0,0 +1,20 @@ +{ + "remedy_base_url": "http://vw-isr-dbasp010.adprod.bmc.com:8008/api/arsys/v1", + "remedy_form": "CHG:ChangeInterface_Create", + "username": "", + "password": "", + "verify_ssl": false, + "ticket_defaults": { + "First Name": "App", + "Last Name": "Admin", + "Impact": "4-Minor/Localized", + "Urgency": "4-Low", + "Location Company": "Calbro Services", + "Detailed Description": "This is an automatic test ticket opened from CONTROL-M WCM Change Management Integration.", + "ASCPY": "Calbro Services", + "ASORG": "IT Support", + "ASGRP": "Backoffice Support", + "ASCHG": "", + "ASLOGID": "" + } +} diff --git a/saas-control-m/302-wcm-event-integration-remedy/remedy_client.py b/saas-control-m/302-wcm-event-integration-remedy/remedy_client.py new file mode 100644 index 0000000..65cd387 --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/remedy_client.py @@ -0,0 +1,87 @@ +"""Remedy ITSM client – authentication and change-request creation.""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +import requests + +ITSM_CONFIG_FILE = Path(__file__).parent / "itsm_config.json" + +logger = logging.getLogger("remedy_client") + + +def load_itsm_config() -> dict: + if not ITSM_CONFIG_FILE.exists(): + logger.error("ITSM config not found: %s", ITSM_CONFIG_FILE) + sys.exit(1) + with open(ITSM_CONFIG_FILE, encoding="utf-8") as f: + return json.load(f) + + +def get_auth_token(base_url: str, username: str, password: str, verify_ssl: bool) -> str: + """Authenticate against Remedy and return a JWT token.""" + auth_url = f"{base_url.rsplit('/api', 1)[0]}/api/jwt/login" + resp = requests.post( + auth_url, + data={"username": username, "password": password}, + verify=verify_ssl, + timeout=30, + ) + resp.raise_for_status() + return resp.text + + +def create_change_request(config: dict, alert: dict) -> str | None: + """Create a Remedy change request and return its ID.""" + base_url = config["remedy_base_url"] + verify_ssl = config.get("verify_ssl", True) + form = config.get("remedy_form", "CHG:ChangeInterface_Create") + + token = get_auth_token( + base_url, config["username"], config["password"], verify_ssl + ) + + defaults = dict(config.get("ticket_defaults", {})) + + description = f"Control-M Request {alert.get('name', '')} by {alert.get('endUser', '')}" + defaults["Description"] = description + + payload = {"values": defaults} + + url = f"{base_url}/entry/{form}" + headers = { + "Authorization": f"AR-JWT {token}", + "Content-Type": "application/json", + } + + logger.info("Creating Remedy change request for workspace '%s'", alert.get("name")) + logger.info("Payload: %s", json.dumps(payload, indent=2)) + resp = requests.post( + url, headers=headers, json=payload, verify=verify_ssl, timeout=30 + ) + if not resp.ok: + logger.error("Remedy response [%d]: %s", resp.status_code, resp.text) + resp.raise_for_status() + + location = resp.headers.get("Location", "") + ticket_id = location.rsplit("/", 1)[-1] if location else None + logger.info("Remedy change request created: %s", ticket_id or "(see response)") + return ticket_id + + +def release_token(base_url: str, token: str, verify_ssl: bool): + """Log out / release the JWT token.""" + logout_url = f"{base_url.rsplit('/api', 1)[0]}/api/jwt/logout" + try: + requests.post( + logout_url, + headers={"Authorization": f"AR-JWT {token}"}, + verify=verify_ssl, + timeout=10, + ) + except Exception: + logger.debug("Token logout failed (non-critical)") diff --git a/saas-control-m/302-wcm-event-integration-remedy/requirements.txt b/saas-control-m/302-wcm-event-integration-remedy/requirements.txt new file mode 100644 index 0000000..82a1d47 --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/requirements.txt @@ -0,0 +1,2 @@ +websocket-client>=1.6.0 +requests>=2.31.0 diff --git a/saas-control-m/302-wcm-event-integration-remedy/script.sh b/saas-control-m/302-wcm-event-integration-remedy/script.sh new file mode 100644 index 0000000..025022d --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/script.sh @@ -0,0 +1,8 @@ +#!/bin/sh +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Saving alerts data to the log file" +echo "$*" >> "$SCRIPT_DIR/log.out" + +echo "Invoking alert handler" +python3 "$SCRIPT_DIR/handle_alert.py" "$@" \ No newline at end of file diff --git a/saas-control-m/302-wcm-event-integration-remedy/settings.json b/saas-control-m/302-wcm-event-integration-remedy/settings.json new file mode 100644 index 0000000..bb02376 --- /dev/null +++ b/saas-control-m/302-wcm-event-integration-remedy/settings.json @@ -0,0 +1,10 @@ +{ + "endpoint": "https:///automation-api", + "apiKey": "", + "handlerScript": "", + "proxyUrl": "", + "logLevel": "INFO", + "logDir": "logs", + "logSizeMb": 10, + "maxLogFiles": 10 +} From 5360e05de90c83ee821f044578d8cbdbe481a411 Mon Sep 17 00:00:00 2001 From: ebuchbut Date: Sun, 14 Jun 2026 12:06:23 +0300 Subject: [PATCH 2/2] WBL-396559 Add note about placeholder configuration values --- saas-control-m/302-wcm-event-integration-remedy/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/saas-control-m/302-wcm-event-integration-remedy/README.md b/saas-control-m/302-wcm-event-integration-remedy/README.md index ce5c883..c945fe5 100644 --- a/saas-control-m/302-wcm-event-integration-remedy/README.md +++ b/saas-control-m/302-wcm-event-integration-remedy/README.md @@ -124,6 +124,8 @@ chmod +x script.sh Adjust `ticket_defaults` to match your Remedy environment (company, support group, assignee, etc.). +> **Note:** This example uses placeholder values (e.g., ``, ``). Replace them with your own environment-specific details. + > **Security:** Do not commit real credentials. Restrict file permissions on `itsm_config.json` and `settings.json`. ## Connect WCM Integration to this handler