Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"*.sh text eol=lf"
190 changes: 190 additions & 0 deletions saas-control-m/302-wcm-event-integration-remedy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# 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://<your-controlm-host>/automation-api",
"apiKey": "<your-api-key>"
}
```

| 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://<remedy-host>:<port>/api/arsys/v1",
"remedy_form": "CHG:ChangeInterface_Create",
"username": "<remedy-user>",
"password": "<remedy-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.).

> **Note:** This example uses placeholder values (e.g., `<your-api-key>`, `<your-password>`). 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

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.
188 changes: 188 additions & 0 deletions saas-control-m/302-wcm-event-integration-remedy/handle_alert.py
Original file line number Diff line number Diff line change
@@ -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: <v> ctmRequestId: <v> name: <v> newState: <v> oldState: <v> ...
"""

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()
Loading