cancel
Showing results for 
Search instead for 
Did you mean: 
cancel
132
Views
2
Helpful
2
Comments
Ahmadalkayyali
Level 4
Level 4
Ahmadkay_0-1783971579199.png

Automating Cisco Voice Administration with Python

Applies to: Cisco Unified Communications Manager (CUCM) 12.5, 14, and 15 Audience: Voice and UC engineers with basic Python experience Topics: AXL · Bulk Updates · Reporting · Validation · Change Tracking


⚠️ Before you run any of this against production

Every code sample below is a working excerpt from the reference implementation in §16 — but three things in this article are version-dependent and must be confirmed against your own cluster before use:

Item Why it varies How to confirm
zeep object shapes (_value_1, resp["return"]["phone"])Depends on the AXL schema version your WSDL was generated fromPrint a getPhone response in a REPL and inspect it
SQL table/column names in §7.2The database schema changes between CUCM releasesCheck the Data Dictionary for your exact release
AXL fault message text in §8.4Wording differs by releaseTrigger each fault once in a lab and log the real string

The code is syntax-checked and internally consistent. It has not been executed against a live cluster — that step is yours, and it belongs in a lab, not a change window. This article is a pattern, not a drop-in binary.


Overview

Cisco Unified Communications Manager administration is usually handled through the GUI, the Bulk Administration Tool, and standard operational reports. Those tools work. But in large enterprise voice environments, a category of work becomes repetitive: validating device settings, updating descriptions after a site move, checking line configuration, reviewing user-to-device associations, exporting phone inventory, and confirming that a change actually applied.

Python can automate this work in a controlled, repeatable way. The goal is not to replace Cisco Unified CM Administration, BAT, or change control. The goal is to reduce manual effort, improve consistency, and create a repeatable method for reporting, validation, and change tracking.

The interface is the Administrative XML Web Service (AXL) — Cisco's XML/SOAP provisioning API for inserting, retrieving, updating, and removing configuration data in the Unified CM configuration database.

This article walks the full workflow: connect → read → validate → dry-run → apply → verify → record.


1. Why Automate?

Manual administration is fine for one device, one user, one directory number. The problem appears when the same task repeats across hundreds or thousands of objects. At that scale, the primary risk is no longer time — it is inconsistency.

Typical candidates:

  • Updating phone descriptions after a site move or reassignment
  • Validating calling search spaces across a group of devices
  • Auditing device pool assignments against a site standard
  • Confirming line-to-user associations
  • Exporting device and DN inventory
  • Comparing pre-change and post-change configuration
  • Identifying phones with missing, outdated, or non-standard values
  • Producing audit records for operational changes

In each case, Python earns its place by doing what the GUI cannot: read the current state, compare it against a standard, decide per object, apply only the changes that are needed, and prove what it did.


2. BAT or Python?

Unified CM already ships with the Bulk Administration Tool. BAT performs bulk transactions against the Unified CM database and automates add, update, delete, and export operations that would otherwise be done one at a time. For a straightforward batch of uniform changes, BAT is often the right answer and it is fully supported.

Use BAT when:

  • The change fits a supported BAT template
  • The update is uniform across all objects
  • CSV import is sufficient
  • You want a standard, Cisco-supported bulk workflow

Use Python with AXL when:

  • Logic is required before the update
  • Each object needs a different decision
  • The current value must be validated before it is changed
  • Pre-change and post-change reporting is required
  • Data must be pulled from another system (ServiceNow, HR feed, asset DB) first
  • The change must be recorded in a custom audit trail

The difference in one example. BAT can push a new common value onto a large group of phones. Python is the better tool when the script must first check each phone, confirm its current device pool, verify the target value is valid for that site, update only the devices that actually differ, skip the exceptions, and produce a before-and-after report.

Rule of thumb: BAT for supported bulk operations. Python when the change requires decision-making, validation, or integration with another system.


3. Prerequisites

3.1 Activate the AXL service

On the Publisher: Cisco Unified Serviceability → Tools → Service Activation → activate Cisco AXL Web Service.

Publisher only for writes. AXL write operations must target the Publisher. Subscribers accept read requests, but provisioning belongs on the Pub. Point automation at the Publisher FQDN.

3.2 Create a dedicated application user

Do not automate with a named human account or with administrator. Create an Application User (e.g. axl-automation) and assign the minimum roles required:

  • Standard AXL API Access
  • Standard CCM Admin Users

Reporting-only jobs get a separate, read-only account. Least privilege applies here as it does everywhere else.

3.3 Download the WSDL for your release

Cisco Unified CM Administration → Application → Plugins → download the AXL Toolkit. Extract the schema into a version-pinned directory:

schema/
  14.0/
    AXLAPI.wsdl
    AXLEnums.xsd
    AXLSoap.xsd

The WSDL must match your cluster's major version. A 12.5 schema pointed at a 14 cluster will mostly work — the worst kind of failure, because it silently omits fields introduced in the newer release. Cisco's guidance is to adopt the latest available AXL schema and review schema changes when moving between versions. Re-test all automation after every CUCM upgrade.

3.4 Certificates

Export the Tomcat certificate from OS Administration → Security → Certificate Management and reference it as a CA bundle. Disabling TLS verification is acceptable in a lab and unacceptable in production — you are sending administrative credentials over that socket.


4. The AXL Client

pip install zeep requests pydantic

zeep is the SOAP client. Parsing the AXL WSDL is slow, so cache it.

# src/axl_client.py
import os
from pathlib import Path

from requests import Session
from requests.auth import HTTPBasicAuth
from zeep import Client, Settings
from zeep.cache import SqliteCache
from zeep.transports import Transport

BINDING = "{http://www.cisco.com/AXLAPIService/}AXLAPIBinding"


def get_axl(host: str, username: str, password: str,
            wsdl_dir: str = "schema/14.0",
            ca_bundle: str | bool = True,
            timeout: int = 30):
    """Return an AXL service proxy bound to the CUCM Publisher."""
    wsdl = Path(wsdl_dir, "AXLAPI.wsdl").absolute().as_uri()

    session = Session()
    session.auth = HTTPBasicAuth(username, password)
    session.verify = ca_bundle          # Tomcat CA cert. Never False in prod.

    transport = Transport(session=session, cache=SqliteCache(), timeout=timeout)
    settings = Settings(strict=False, xml_huge_tree=True)

    client = Client(wsdl, settings=settings, transport=transport)
    return client.create_service(BINDING, f"https://{host}:8443/axl/")


def get_axl_from_env():
    """Credentials come from the environment, never from code."""
    return get_axl(
        host=os.environ["CUCM_PUB"],
        username=os.environ["AXL_USER"],
        password=os.environ["AXL_PASS"],
        wsdl_dir=os.environ.get("AXL_WSDL_DIR", "schema/14.0"),
        ca_bundle=os.environ.get("CUCM_CA_BUNDLE", True),
    )


def smoke_test(axl) -> str:
    """Confirm connectivity, credentials, roles, and TLS trust in one call."""
    return axl.getCCMVersion()["return"]["componentVersion"]["version"]

Run the smoke test first:

>>> from axl_client import get_axl_from_env, smoke_test
>>> smoke_test(get_axl_from_env())
'14.0.1.13900-155'

If that returns a version string, you have a working client, valid credentials, correct roles, and a trusted certificate. If it fails, you have exactly one of those four problems — check them in that order.


5. The Automation Model

Every script below follows the same shape. Nothing goes straight from a CSV to an update.

Input file (the intent)
   │   approved CSV / Excel / ServiceNow export
   ▼
Input validation ......... Is the request even coherent?     → abort on error
   ▼
Pre-flight validation .... Do the referenced objects exist?  → abort on error
   ▼
Plan (diff) .............. What actually differs? Skip the rest.
   ▼
Dry run .................. Print the plan. Change nothing.   ← DEFAULT
   ▼
Rollback file ............ Capture every OLD value first.
   ▼
Apply .................... Per device, isolating failures.
   ▼
Verify ................... Re-read. A 200 is not proof.
   ▼
Record ................... Audit log + snapshot diff.

A script that only performs updates is dangerous. A script that validates, updates, and validates again is operationally useful.


6. The Data Model

Two small types carry the whole workflow: an intent (what we want) and a change (what differs).

# src/models.py
import re
from dataclasses import dataclass

from pydantic import BaseModel, field_validator

# CSV column  ->  AXL element name
FIELD_MAP = {
    "description": "description",
    "device_pool": "devicePoolName",
    "css": "callingSearchSpaceName",
}

# Ask AXL only for the fields we actually use.
RETURNED_TAGS = {
    "name": "",
    "description": "",
    "devicePoolName": "",
    "callingSearchSpaceName": "",
}

DEVICE_NAME_RE = re.compile(r"SEP[0-9A-F]{12}")


class PhoneIntent(BaseModel):
    """One row of the approved input file."""
    device: str
    description: str
    device_pool: str
    css: str

    @field_validator("device")
    @classmethod
    def valid_device_name(cls, v: str) -> str:
        v = v.strip().upper()
        if not DEVICE_NAME_RE.fullmatch(v):
            raise ValueError(f"{v!r} is not a valid SEP device name")
        return v


@dataclass
class Change:
    """A single field on a single object that needs to change."""
    device: str
    field: str          # AXL element name, e.g. 'devicePoolName'
    old: str | None
    new: str

The input file is the intent. The script's only job is to make CUCM match it.

device,description,device_pool,css
SEP001122334455,DAL - Sales - 512345 - jsmith,SiteA-DP,SiteA_Internal
SEP00AABBCCDDEE,DAL - Sales - 512346 - mjones,SiteA-DP,SiteA_Internal

7. Reading Data

7.1 get vs list

Choosing wrong here is the most common cause of "my script is slow and the Publisher CPU is pinned."

  • getPhone — one object, full detail. Use when you already know the device.
  • listPhone — many objects, only the fields you ask for. Use for inventory and bulk work.

Three rules for every list call:

  1. Always set returnedTags. Requesting every field on 5,000 phones is how you generate a throttling incident.
  2. Always page. Use first / skip and loop until you get a short page.
  3. Always constrain searchCriteria. % matches the entire cluster; that is rarely what you meant.
# src/inventory.py
from models import RETURNED_TAGS


def axl_text(value):
    """Unwrap an AXL element to a plain string.

    zeep renders many AXL elements as objects carrying the text in
    `_value_1` (because they also carry a `uuid` attribute), while others
    come back as plain strings. The exact shape varies by schema version,
    so never index `._value_1` directly -- go through this helper.
    """
    if value is None:
        return None
    return getattr(value, "_value_1", value)


def list_all(method, criteria: dict, tags: dict, page: int = 500):
    """Yield every object matching `criteria`, one page at a time."""
    skip = 0
    while True:
        resp = method(searchCriteria=criteria, returnedTags=tags,
                      first=page, skip=skip)
        rows = resp["return"]
        if not rows:
            return
        batch = rows[next(iter(rows))]          # 'phone', 'line', 'user', ...
        for item in batch:
            yield item
        if len(batch) < page:
            return
        skip += page


def current_values(phone) -> dict:
    """Current AXL values for the fields this tool manages."""
    return {
        "description": axl_text(phone.description),
        "devicePoolName": axl_text(phone.devicePoolName),
        "callingSearchSpaceName": axl_text(phone.callingSearchSpaceName),
    }


def phone_inventory(axl, name_filter: str = "SEP%") -> list[dict]:
    """Flat phone inventory, suitable for CSV export or a snapshot."""
    rows = []
    for p in list_all(axl.listPhone, {"name": name_filter}, RETURNED_TAGS):
        values = current_values(p)
        rows.append({
            "device": axl_text(p.name),
            "description": values["description"],
            "device_pool": values["devicePoolName"],
            "css": values["callingSearchSpaceName"],
        })
    return sorted(rows, key=lambda r: r["device"])

Note the axl_text() helper. Do not sprinkle ._value_1 through your code. Whether a given element arrives as an object or a bare string depends on the schema version — funnel every read through one place so an upgrade breaks one function instead of forty.

7.2 Thick AXL for larger reports

For cluster-wide reporting that would otherwise take many list calls:

# src/inventory.py (continued)
def sql(axl, query: str) -> list[dict]:
    """Thick AXL. Read-only. Verify the schema against YOUR release."""
    resp = axl.executeSQLQuery(sql=query)
    rows = resp["return"]
    if not rows:
        return []
    return [{col.tag: col.text for col in row} for row in rows["row"]]
-- Phones by model and device pool
-- ⚠ VERIFY against the Data Dictionary for YOUR CUCM release before use.
SELECT d.name, d.description, tm.name AS model, dp.name AS devicepool
FROM device d
JOIN typemodel tm ON d.tkmodel = tm.enum
JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE d.tkclass = 1

Three constraints, all of which matter:

  • Prefer standard AXL methods where they exist. Cisco's guidance is to avoid executeSQLQuery / executeSQLUpdate when a documented method will do, because these bypass the AXL database abstraction layer and the schema can change between releases.
  • executeSQLUpdate is off limits. It writes directly to the database and bypasses CUCM's business logic. If a task appears to require it, the task is wrong. Use the proper AXL method, or open a TAC case.
  • Validate the SQL against your release. Table and column names shift between versions — including in the query above.

7.3 AXL does not know registration status

This one trips up almost everyone. Registration state lives in RIS, not the CUCM configuration database. AXL tells you a phone exists and how it is configured. It will not tell you whether it is registered.

For "which phones are unregistered," use the RisPort70 service (selectCmDevice) and join the result to your AXL inventory. Keep the two data sources clearly separated in your reporting code.

7.4 Reporting is the right place to start

Read-only automation is the safest and often the most valuable. Before automating changes, automate visibility:

  • Phones by device pool or calling search space
  • Directory numbers with no description
  • Devices with no owner user ID
  • Users with no controlled devices
  • Lines assigned to multiple devices
  • Phones missing the expected common phone profile
  • Site-based device inventory

Cisco also provides CDR and CAR for call activity, QoS, traffic, user call volume, billing, and gateway reporting. Joining AXL configuration data to CDR/CMR, CUIC, ticketing, or asset inventory is usually where the real reporting value lives.

Schedule the reporting jobs. Nobody should be building a phone inventory by hand.


8. Validation

Validation is what makes automation acceptable in an enterprise. It happens in three places, and skipping any one of them is how you find out why it was there.

# src/validators.py
import csv

from pydantic import ValidationError
from zeep.exceptions import Fault

from inventory import axl_text, current_values
from models import FIELD_MAP, Change, PhoneIntent


def load_intents(path: str) -> tuple[list[PhoneIntent], list[str]]:
    """Parse and schema-validate the approved input file."""
    intents, errors = [], []
    with open(path, newline="") as f:
        for line_no, row in enumerate(csv.DictReader(f), start=2):
            try:
                intents.append(PhoneIntent(**row))
            except ValidationError as e:
                errors.append(f"{path}:{line_no}: {e.errors()[0]['msg']}")
    return intents, errors


def existing_names(list_method, key: str) -> set[str]:
    """All configured names for a reference object (device pool, CSS, ...)."""
    resp = list_method(searchCriteria={"name": "%"}, returnedTags={"name": ""})
    if not resp["return"]:
        return set()
    return {axl_text(row.name) for row in resp["return"][key]}


def preflight(axl, intents: list[PhoneIntent]) -> list[str]:
    """Confirm every referenced object exists BEFORE touching a device."""
    valid_pools = existing_names(axl.listDevicePool, "devicePool")
    valid_css = existing_names(axl.listCss, "css")

    errors = []
    for intent in intents:
        if intent.device_pool not in valid_pools:
            errors.append(
                f"{intent.device}: unknown device pool {intent.device_pool!r}")
        if intent.css not in valid_css:
            errors.append(f"{intent.device}: unknown CSS {intent.css!r}")
    return errors


def fetch_phone(axl, device: str):
    """Return the phone, or None if it does not exist."""
    try:
        return axl.getPhone(name=device)["return"]["phone"]
    except Fault:
        return None


def diff_phone(intent: PhoneIntent, phone) -> list[Change]:
    """Fields where CUCM does not match the intent. Empty list == no work."""
    current = current_values(phone)
    changes = []
    for csv_field, axl_field in FIELD_MAP.items():
        wanted = getattr(intent, csv_field)
        if wanted and wanted != current[axl_field]:
            changes.append(
                Change(intent.device, axl_field, current[axl_field], wanted))
    return changes


def verify(axl, device: str, expected: dict) -> list[str]:
    """Re-read the object. A successful AXL response is not proof."""
    phone = fetch_phone(axl, device)
    if phone is None:
        return [f"{device}: disappeared after update"]
    actual = current_values(phone)
    return [
        f"{device}.{field}: expected {value!r}, got {actual.get(field)!r}"
        for field, value in expected.items()
        if actual.get(field) != value
    ]

Three points worth pulling out:

  1. preflight() fails the entire run, not the individual row. The most common bulk-update failure is a device pool or CSS spelled almost correctly. A partially applied bulk change is worse than no change, because now nobody knows what state the cluster is in.
  2. diff_phone() returning an empty list makes the script idempotent. Running it twice is safe, and the second run must report zero changes. If a re-run keeps applying the "same" change, the diff logic is wrong — fix that before going near production.
  3. verify() exists because a successful AXL response only means CUCM accepted the request. Re-read the object and confirm.

9. Change Tracking

CUCM's audit logs will tell you that axl-automation changed something at 02:14. They will not tell you which ticket, which engineer, or what the value was before. That context comes from your tooling.

# src/change_tracker.py
import csv
import getpass
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path

from inventory import phone_inventory
from models import Change


class ChangeTracker:
    """One instance per run. Writes an append-only audit record."""

    def __init__(self, ticket: str, input_file: str, cluster: str,
                 outdir: str = "output"):
        self.run_id = str(uuid.uuid4())
        self.ticket = ticket
        self.input_file = input_file
        self.cluster = cluster
        self.engineer = getpass.getuser()
        self.outdir = Path(outdir)
        self.outdir.mkdir(parents=True, exist_ok=True)
        self.log_path = self.outdir / "change_log.jsonl"
        self.rollback_path = self.outdir / f"rollback_{self.run_id}.csv"

    def write_rollback(self, changes: list[Change]) -> Path:
        """Capture every OLD value BEFORE anything is applied."""
        with open(self.rollback_path, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["device", "field", "restore_to"])
            for c in changes:
                w.writerow([c.device, c.field, c.old])
        return self.rollback_path

    def record(self, change: Change, result: str, error: str | None = None):
        """One line per field touched. Append-only."""
        entry = {
            "run_id": self.run_id,
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "engineer": self.engineer,
            "ticket": self.ticket,
            "cluster": self.cluster,
            "input_file": self.input_file,
            "object_type": "Phone",
            "object_name": change.device,
            "field": change.field,
            "old_value": change.old,
            "new_value": change.new,
            "result": result,
            "error": error,
        }
        with open(self.log_path, "a") as f:
            f.write(json.dumps(entry) + "\n")


def snapshot(axl, path: str = "state/phones.json"):
    """Normalized, sorted config dump. Commit before and after the run;
    `git diff` on this file is the cleanest change report you will get."""
    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)
    data = phone_inventory(axl)
    with open(out, "w") as f:
        json.dump(data, f, indent=2, sort_keys=True)
    return out

The rollback file is written before the first write, not after the incident. The diff already captured every old value; persisting it costs nothing and is the difference between a reversible change and a gamble.

Snapshot diffs give you a change report for free:

python -m src.change_tracker --out state/phones.json
git commit -am "pre-change snapshot: CHG0041982"

python -m src.main --input-file input/approved_changes.csv \
                   --ticket-number CHG0041982 --apply

python -m src.change_tracker --out state/phones.json
git diff state/phones.json          # <-- this is your change record

A git diff on a sorted, normalized snapshot is the cleanest change report you will ever produce for a UC cluster. It doubles as a disaster-recovery artifact.


10. Putting It Together: main.py

This is the orchestrator. Every fragment above is imported here — nothing is left dangling.

# src/main.py
"""Bulk phone update: read -> validate -> dry run -> apply -> verify -> record.

Dry run is the DEFAULT. Writing requires an explicit --apply.
"""
import argparse
import os
import sys
import time
from collections import defaultdict

from zeep.exceptions import Fault

from axl_client import get_axl_from_env, smoke_test
from change_tracker import ChangeTracker
from models import Change
from validators import diff_phone, fetch_phone, load_intents, preflight, verify

WRITE_DELAY_SECONDS = 0.2      # be kind to the Publisher


def parse_args():
    p = argparse.ArgumentParser(description="Bulk-update CUCM phones via AXL.")
    p.add_argument("--input-file", required=True)
    p.add_argument("--ticket-number", required=True)
    p.add_argument("--apply", action="store_true",
                   help="write to CUCM (default: dry run)")
    p.add_argument("--limit", type=int, default=None,
                   help="process at most N devices")
    p.add_argument("--site", default=None,
                   help="only devices whose description starts with this site code")
    return p.parse_args()


def plan(axl, intents) -> tuple[list[Change], list[str]]:
    """Build the change list. Returns (changes, skipped_reasons)."""
    changes: list[Change] = []
    skipped: list[str] = []

    for intent in intents:
        phone = fetch_phone(axl, intent.device)
        if phone is None:
            skipped.append(f"{intent.device}: not found in CUCM")
            continue

        device_changes = diff_phone(intent, phone)
        if not device_changes:
            skipped.append(f"{intent.device}: already correct")
            continue

        changes.extend(device_changes)

    return changes, skipped


def print_plan(changes: list[Change], skipped: list[str]):
    print(f"{'Device':<18} {'Field':<24} {'Current':<24} {'Target':<24}")
    print("-" * 92)
    for c in changes:
        print(f"{c.device:<18} {c.field:<24} {str(c.old):<24} {c.new:<24}")
    for s in skipped:
        print(f"SKIPPED  {s}")
    print("-" * 92)
    print(f"{len(changes)} change(s) across "
          f"{len({c.device for c in changes})} device(s); {len(skipped)} skipped.")


def apply_changes(axl, changes: list[Change], tracker: ChangeTracker) -> int:
    """Apply per device, isolating failures. Returns the failure count."""
    grouped: dict[str, list[Change]] = defaultdict(list)
    for c in changes:
        grouped[c.device].append(c)

    failures = 0
    for device, device_changes in grouped.items():
        payload = {c.field: c.new for c in device_changes}
        try:
            axl.updatePhone(name=device, **payload)
        except Fault as e:
            failures += 1
            for c in device_changes:
                tracker.record(c, result="failed", error=str(e))
            continue        # one bad device must not abort the run

        problems = verify(axl, device, payload)
        if problems:
            failures += 1
            for c in device_changes:
                tracker.record(c, result="unverified", error="; ".join(problems))
        else:
            for c in device_changes:
                tracker.record(c, result="updated")

        time.sleep(WRITE_DELAY_SECONDS)

    return failures


def main() -> int:
    args = parse_args()
    axl = get_axl_from_env()
    print(f"Connected to {os.environ['CUCM_PUB']} (CUCM {smoke_test(axl)})")

    # 1. Input validation -- is the request even coherent?
    intents, input_errors = load_intents(args.input_file)
    if input_errors:
        for e in input_errors:
            print("INPUT ERROR:", e, file=sys.stderr)
        return 1

    if args.site:
        intents = [i for i in intents if i.description.startswith(args.site)]
    if args.limit:
        intents = intents[:args.limit]

    # 2. Pre-flight -- do the referenced objects exist? Fail the WHOLE run.
    for e in preflight(axl, intents):
        print("VALIDATION ERROR:", e, file=sys.stderr)
        input_errors.append(e)
    if input_errors:
        print("Aborting. No changes were made.", file=sys.stderr)
        return 1

    # 3. Plan -- diff intent against reality.
    changes, skipped = plan(axl, intents)
    print_plan(changes, skipped)

    if not changes:
        print("Nothing to do.")
        return 0

    # 4. Dry run is the default.
    if not args.apply:
        print("\nDRY RUN -- nothing applied. Re-run with --apply.")
        return 0

    # 5. Rollback file is written BEFORE the first write.
    tracker = ChangeTracker(ticket=args.ticket_number,
                            input_file=args.input_file,
                            cluster=os.environ["CUCM_PUB"])
    print(f"Rollback file: {tracker.write_rollback(changes)}")
    print(f"Run ID:        {tracker.run_id}")

    # 6. Apply + verify + record.
    failures = apply_changes(axl, changes, tracker)
    print(f"\nDone. {len(changes) - failures} change(s) applied, "
          f"{failures} device(s) failed or unverified.")
    print(f"Audit log: {tracker.log_path}")
    return 1 if failures else 0


if __name__ == "__main__":
    raise SystemExit(main())

Dry run (the default)

$ export CUCM_PUB=cucm-pub.example.com AXL_USER=axl-automation
$ export AXL_PASS='...' CUCM_CA_BUNDLE=certs/tomcat.pem
$ python -m src.main --input-file input/approved_changes.csv \
                     --ticket-number CHG0041982

Connected to cucm-pub.example.com (CUCM 14.0.1.13900-155)
Device             Field                    Current                  Target
--------------------------------------------------------------------------------------------
SEP00AABBCCDDEE    callingSearchSpaceName   SiteA_Old                SiteA_Internal
SEP00AABBCCDDEE    description              DAL - Sales - 512346     DAL - Sales - 512346 - mjones
SKIPPED  SEP001122334455: already correct
SKIPPED  SEP00FFEEDDCCBB: not found in CUCM
--------------------------------------------------------------------------------------------
2 change(s) across 1 device(s); 2 skipped.

DRY RUN -- nothing applied. Re-run with --apply.

--apply is the explicit, deliberate flag. A script that writes by default will eventually be run by someone who believed it was read-only.


11. Error Handling

except Fault as e:
    tracker.record(c, result="failed", error=str(e))
    continue        # one bad device must not abort the run

Common AXL faults:

Fault Usual cause
Item not valid: The specified {item} was not found in the databaseTypo in a device pool, CSS, or device name
Could not insert new row - duplicate valueObject already exists
User not authorizedApplication user missing the AXL role
503 Service UnavailableWrite throttling — back off and retry
Timeouts during a large jobYou are hammering the Publisher. Slow down.

The exact fault strings vary by release. Trigger each condition once in a lab, log the real message, and match on the AXL error code rather than the text where you can.


12. AXL Performance and Safety

AXL is a provisioning interface, not a real-time monitoring interface. Cisco states that AXL is not a real-time API and that too many requests in quick succession may be throttled. Write requests can be throttled dynamically based on the Unified CM database transaction queue, and AXL may return 503 Service Unavailable when the queue is too large — applications should retry only after a reasonable delay.

Guardrails:

  • Run write automation against the Publisher.
  • Use a dedicated AXL application user with the minimum required roles.
  • Keep concurrency low. Serial is fine; four workers is plenty.
  • Add a delay between write operations (WRITE_DELAY_SECONDS above).
  • Batch rather than looping without bounds.
  • Build retry-with-backoff for 503s and transient failures.
  • Log every request and every response.
  • Never ignore a failed response.
  • Avoid large jobs during peak business hours.
  • Test in a lab first — every time, including for "just a description field."
  • Always dry-run before production execution.
  • Pin the WSDL to your cluster version and re-test after every upgrade.

13. Worked Example: Phone Description Standardization

Descriptions drift as devices are moved, reassigned, and renamed by hand. Standardizing them exercises the whole model.

Target format: <SiteCode> - <Department> - <Extension> - <UserID>

The input file expresses that format; main.py does the rest unchanged.

Device Name      Old Description        New Description                 Result
SEP001122334455  John Desk Phone        DAL - Sales - 512345 - jsmith   Updated
SEP00AABBCCDDEE  DAL - Sales - 512346   DAL - Sales - 512346 - mjones   Updated
SEP00FFEEDDCCBB  HR Lobby               HR Lobby                        Skipped - Excluded

Simple, but it demonstrates the full loop: read, validate, diff, apply, verify, log.


14. Worked Example: Device Pool Drift Detection

A read-only report — and one of the highest-value scripts you can write.

Logic: if a phone belongs to Site A (by name pattern or DN range), its expected device pool is SiteA-DP. If it is assigned elsewhere, flag it. Do not auto-remediate. Report only, pending approval.

Device Name      DN      Current Device Pool   Expected Device Pool   Status
SEP001122334455  512345  SiteA-DP              SiteA-DP               OK
SEP00AABBCCDDEE  512346  Default-DP            SiteA-DP               Mismatch
SEP00FFEEDDCCBB  612345  SiteB-DP              SiteB-DP               OK

Device pool drift sits upstream of routing, CSS, media resource, and SRST behavior. Catching it in a weekly report is considerably cheaper than catching it during an outage.


15. Project Layout

cucm-automation/
│
├── schema/14.0/                 # AXL WSDL, pinned to cluster version
├── config/settings.example.yml
├── input/approved_changes.csv   # the intent
├── state/phones.json            # committed snapshots -- git is the history
├── output/
│   ├── change_log.jsonl
│   └── rollback_<run_id>.csv
├── logs/automation.log
│
├── src/
│   ├── axl_client.py            # connection + auth          (§4)
│   ├── models.py                # PhoneIntent, Change        (§6)
│   ├── inventory.py             # list_all, sql, snapshots   (§7)
│   ├── validators.py            # input + pre-flight + diff  (§8)
│   ├── change_tracker.py        # audit log, rollback        (§9)
│   └── main.py                  # orchestrator               (§10)
│
├── tests/                       # run against a lab cluster, never prod
└── README.md

Credentials are never stored in code. Use environment variables, a secrets manager, or another approved internal method.


16. Pre-Publication / Pre-Production Checklist

Before this pattern touches a production cluster, confirm each item in a lab, on your CUCM version:

  • [ ] smoke_test() returns your cluster's version string
  • [ ] getPhone response inspected; axl_text() correctly unwraps every field you read
  • [ ] list_all() pages correctly past 500 objects
  • [ ] Any executeSQLQuery SQL validated against the Data Dictionary for your release
  • [ ] preflight() correctly rejects a deliberately misspelled device pool
  • [ ] diff_phone() returns an empty list on a second run (idempotency proven)
  • [ ] Dry run output reviewed and approved by a second engineer
  • [ ] Rollback file generated and manually re-applied successfully
  • [ ] Each AXL fault in §11 triggered once and the real message logged
  • [ ] Audit log reviewed; ticket number, engineer, and old/new values all present
  • [ ] Snapshot git diff matches the change log exactly

17. Adoption Path

  1. Build read-only reports.
  2. Add validation logic.
  3. Add dry-run mode.
  4. Add controlled updates.
  5. Add post-change validation.
  6. Add audit logging.
  7. Add rollback output.
  8. Schedule the low-risk, read-only jobs.
  9. Keep all write operations under formal change control.

Automation should make Cisco voice administration safer, not merely faster at the expense of control. The best script is not the one that updates the most objects. It is the one that updates only the correct objects, skips the wrong ones, explains every decision, and leaves a clear audit trail.


18. Where to Go Next

  • Extend the pattern to end users and lines (listUser, updateUser, updateLine) — the diff/validate/apply skeleton does not change, only FIELD_MAP and the intent model.
  • Add RisPort70 for real-time registration data and join it to your AXL inventory.
  • Wire the scripts into your ticketing system so the ticket number populates automatically.
  • Schedule the reporting jobs. Keep the write jobs human-approved.

Conclusion

Python automation improves Cisco voice administration when it is built around AXL, validation, reporting, and change tracking. AXL provides controlled configuration access. BAT remains the right tool for standard bulk operations. Python fills the gap where logic, verification, and integration are required.

The value is not in the update itself. It is in the full workflow: pre-check, approved input, controlled change, post-check, reporting, and rollback readiness.

Make the intent data. Make the change reversible. Make the record automatic.
Everything else is just SOAP.

Sample Python automation scripts are available here:
https://github.com/ahmadalkayyali/cisco-cucm-axl-python-toolkit


References

Pull the copy that matches your release — these documents are version-specific.

  • Cisco. AXL Developer Guide — Administrative XML Web Service. Cisco DevNet. https://developer.cisco.com/docs/axl/
  • Cisco. Staying Updated with AXL Schemas. Cisco DevNet.
  • Cisco. Bulk Administration Guide for Cisco Unified Communications Manager, Release 15 and SUs.
  • Cisco. Cisco Unified CDR Analysis and Reporting Administration Guide.
  • Cisco. Cisco Unified Communications Manager Data Dictionary (for your specific release).
  • Cisco. Serviceability API Guide — RisPort70 (device registration status).
Comments

Outstanding article!

Ahmadalkayyali
Level 4
Level 4

Thank you so much, Roger. I really appreciate it. I’ve been collecting real world Cisco voice notes for a long time, and I’m finally turning them into articles that can hopefully help other engineers. Your feedback means a lot, especially coming from a Cisco Community VIP.

Getting Started

Find answers to your questions by entering keywords or phrases in the Search bar above. New here? Use these resources to familiarize yourself with the community: