A validated, auditable layer in front of repetitive call-forwarding changes on Cisco Unified Communications Manager (CUCM). The desired state lives in a reviewed input file, not in an individual's memory. Every change is validated before it runs, planned as a diff, applied idempotently, verified, and captured for rollback.
This is a reference implementation of the pattern described in the article Automating Call Forwarding Management at Enterprise Scale. It ships with an offline mock CUCM so you can run the full workflow — validation, dry-run, apply, verify, rollback — without a live cluster.
The CUCM administration GUI will happily accept a forward whose Calling Search
Space cannot reach the destination. The change saves successfully and the
routing failure only appears when a caller hits it. cfwd moves those checks
before the change, and makes every change reviewable and reversible.
- Validation first — DN existence, destination existence, CSS reachability, forwarding-loop detection, and required change metadata (reason + ticket).
- Dry-run by default — the plan is a current → desired diff. Nothing is
written unless you pass
--apply. - Idempotent apply — a line already in the desired state produces no call. Re-running a failed batch is safe.
- Defined partial-failure behavior — stop on first failure by default,
report what was applied, leave a rollback file for those rows.
--continue-on-erroropts into partial application. - Rollback captured before the change, not reconstructed after a failure. The rollback file is itself a valid input file.
- Voicemail modeled correctly — forward-to-voicemail is a boolean flag, matching the CUCM data model, not a fake destination string.
git clone https://github.com/ahmadalkayyali/cfwd.git
cd cfwd
export PYTHONPATH=. # or: pip install -e .
# 1. Look at a directory number
python -m cfwd.cli status --dn 513100
# 2. Dry-run a batch (default: nothing is written)
python -m cfwd.cli apply input/approved_forwarding_changes.csv
# 3. Apply it against the offline mock
python -m cfwd.cli apply input/approved_forwarding_changes.csv --apply
# 4. See validation block bad rows before they reach the cluster
python -m cfwd.cli apply input/site-failover-example.csvOnce installed (pip install -e .), the cfwd command is available directly:
cfwd status --dn 513100
cfwd apply input/approved_forwarding_changes.csv --apply
cfwd undo output/rollback_20260714_2117.csv --applyA reviewed CSV is the source of truth for a change:
dn,partition,forward_type,destination,to_voicemail,forward_css,expires_at,reason,ticket
513100,PT_INTERNAL,CFA,513500,false,CSS_INTERNAL,2026-07-18T18:00,Holiday routing,CHG0051204
513103,PT_INTERNAL,CFB,,true,,,Busy to voicemail,CHG0051204| column | meaning |
|---|---|
dn / partition |
the line to change |
forward_type |
CFA, CFB, CFNA, or CFUR |
destination |
target number (leave blank for voicemail or to clear) |
to_voicemail |
true/false — a flag, not a destination |
forward_css |
Calling Search Space used for the forward |
expires_at |
optional; schedules automatic reversal |
reason / ticket |
required change metadata |
The mock is used automatically until you configure a host. For live AXL:
pip install -e ".[axl]" # installs zeep + requests
cp .env.example .env # then fill in host/user/password/wsdl
set -a; source .env; set +a # Linux/macOS: export variables for the CLI| variable | purpose |
|---|---|
CFWD_HOST |
CUCM publisher FQDN |
CFWD_USER / CFWD_PASSWORD |
dedicated AXL application account |
CFWD_WSDL |
path to the AXL WSDL for your CUCM version |
CFWD_MOCK=1 |
force the offline mock even when a host is set |
Credentials are read from the environment or a secret store at run time. They are never written into the code or the input files. Use a dedicated application account with the minimum AXL role required (Standard AXL API Access).
input CSV → Validator → Planner → (dry-run diff) → Executor → verify → report
│
Rollback captured
before apply runs
models.py— plain dataclasses; the rest of the code never sees SOAP.axl_client.py— the only SOAP-aware module.ZeepAXLClientfor live clusters,MockAXLClientfor offline runs and tests.validators.py— the checks that run before anything is applied.planner.py— current → desired diffs; the basis of dry-run and idempotency.executor.py— idempotent apply with defined partial-failure behavior.rollback.py— captures prior state before the change.io_files.py— input parsing and report writing.cli.py—status,apply,undo.
The mock declares CSS→partition routes directly. On a real cluster, resolve
reachability by reading the ordered partition list of the forward CSS through
AXL and confirming the destination's partition appears in it — or, when
translation/route patterns are involved, by validating the call path with
Dialed Number Analyzer or another approved route-analysis method. validators.py isolates this behind
css_can_reach() so you can swap in either strategy.
Rows with an expires_at value are meant to be reversed automatically at that
time by restoring the captured prior state. This repo models expiry as a
first-class field and prints the schedule in the plan; wiring it to a scheduler
(cron, APScheduler, or your job runner) is the one integration point left to the
deploying team, since it depends on your environment.
pip install -e ".[dev]" # installs pytest
pytestThe suite runs entirely against the offline mock and covers validation, loop detection, CSS reachability, idempotency, partial-failure handling, rollback capture, and the voicemail-flag model.
MockAXLClient holds state in memory for the life of a single process, so it
reseeds between separate CLI invocations. Idempotency is a property of the
tool, demonstrated within one process (see tests/test_cfwd.py and the
in-process demo in the article); it is not a property of the throwaway demo
fixture across shell commands.
MIT — see LICENSE.