Config Management · python · 34 copies
F5 UCS Backup
Create a UCS archive on each BIG-IP and download it off the device, with retention. A UCS that only exists on the box it came from is not a backup.
- F5 BIG-IP (TMOS)
$
pip install "requests>=2.28" "PyYAML>=6.0"terminal
f5_ucs_backup.py
#!/usr/bin/env python3
"""
F5 UCS Backup
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/f5-ucs-backup
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28" "PyYAML>=6.0"
"""
import argparse
import datetime
import os
import sys
import requests
import urllib3
import yaml
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# --- iControl REST client -------------------------------------------------
#
# Inlined verbatim rather than imported: these scripts download standalone
# from the site, so there is no shared module to import. The same block
# appears in every F5 script here; change it in all of them or in none.
def credentials():
user = os.environ.get("F5_USER")
password = os.environ.get("F5_PASS")
if not user or not password:
raise SystemExit(
"ERROR: F5_USER and F5_PASS must be set in the environment.\n"
"\n"
" Linux / macOS: export F5_USER=svc_automation\n"
" export F5_PASS='...'\n"
" PowerShell: $env:F5_USER='svc_automation'\n"
" $env:F5_PASS='...'\n"
"\n"
"They are read from the environment on purpose: an argument is\n"
"visible in `ps auxww` while the process runs and lands in shell\n"
"history."
)
return user, password
class BigIP:
"""Minimal iControl REST client.
Token auth rather than Basic on every call: a token is scoped, expires,
and does not put the password on the wire repeatedly.
"""
def __init__(self, host, user, password, verify=True, timeout=30):
self.base = "https://%s/mgmt" % host
self.host = host
self.timeout = timeout
self.s = requests.Session()
self.s.verify = verify
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._login(user, password)
def _login(self, user, password):
r = self.s.post(
self.base + "/shared/authn/login",
json={"username": user, "password": password, "loginProviderName": "tmos"},
timeout=self.timeout,
)
if r.status_code == 401:
raise SystemExit("ERROR: %s rejected the credentials (401)." % self.host)
r.raise_for_status()
token = r.json()["token"]["token"]
self.s.headers.update({"X-F5-Auth-Token": token})
def get(self, path, **kw):
r = self.s.get(self.base + path, timeout=self.timeout, **kw)
r.raise_for_status()
return r.json()
def patch(self, path, body):
r = self.s.patch(self.base + path, json=body, timeout=self.timeout)
r.raise_for_status()
return r.json()
def post(self, path, body):
r = self.s.post(self.base + path, json=body, timeout=self.timeout)
r.raise_for_status()
return r.json()
def load_inventory(path):
"""Read the device inventory. Credentials never live in it."""
try:
with open(path, "r", encoding="utf-8") as fh:
doc = yaml.safe_load(fh) or {}
except FileNotFoundError:
raise SystemExit("ERROR: inventory file not found: %s" % path)
except yaml.YAMLError as exc:
raise SystemExit("ERROR: %s is not valid YAML: %s" % (path, exc))
defaults = doc.get("defaults") or {}
devices = doc.get("devices")
if not devices:
raise SystemExit(
"ERROR: %s has no `devices:` list.\n"
"\n"
" defaults:\n"
" verify_ssl: true\n"
" devices:\n"
" - host: ltm01.example.com\n"
" - host: 10.0.0.10\n"
" verify_ssl: false" % path
)
out = []
for i, d in enumerate(devices, 1):
merged = dict(defaults)
merged.update(d or {})
if not merged.get("host"):
raise SystemExit("ERROR: device #%d in %s has no `host`." % (i, path))
out.append(merged)
return out
def encode(name):
"""tmsh paths use ~ for / in the REST URI: /Common/pool -> ~Common~pool"""
return name.replace("/", "~")
# --- UCS backup -----------------------------------------------------------
CHUNK = 512 * 1024
def create_ucs(bip, name, dry_run):
if dry_run:
print(" [dry-run] would create UCS %s" % name)
return False
bip.post("/tm/sys/ucs", {"command": "save", "name": name})
return True
def download_ucs(bip, name, dest):
"""Pull the archive OFF the device.
This is the whole point. A UCS sitting on the BIG-IP is not a backup: if
the reason you need it is that the device is gone, so is the file.
"""
uri = bip.base + "/shared/file-transfer/ucs-downloads/" + name
start, total = 0, None
with open(dest, "wb") as fh:
while True:
end = start + CHUNK - 1
headers = {"Content-Range": "%d-%d/%s" % (start, end, total or "*")}
r = bip.s.get(uri, headers=headers, timeout=bip.timeout, stream=True)
if r.status_code == 400 and total is not None and start >= total:
break
r.raise_for_status()
crange = r.headers.get("Content-Range", "")
if "/" in crange:
total = int(crange.split("/")[-1])
written = 0
for block in r.iter_content(64 * 1024):
fh.write(block)
written += len(block)
start += written
if written == 0 or (total is not None and start >= total):
break
return os.path.getsize(dest)
def prune(outdir, host, keep):
"""Keep the newest N archives per device, delete the rest."""
mine = sorted(
(f for f in os.listdir(outdir)
if f.startswith(host + "_") and f.endswith(".ucs")),
reverse=True,
)
removed = 0
for old in mine[keep:]:
try:
os.remove(os.path.join(outdir, old))
removed += 1
except OSError as exc:
print(" WARNING: could not remove %s: %s" % (old, exc))
return removed
def main():
ap = argparse.ArgumentParser(
description="Create a UCS archive on each BIG-IP and download it off "
"the device. A UCS that only exists on the box it came "
"from is not a backup.")
ap.add_argument("--inventory", required=True, help="YAML inventory of BIG-IPs")
ap.add_argument("--out", default="./f5-backups", help="Local directory (default ./f5-backups)")
ap.add_argument("--keep", type=int, default=7, help="Archives to keep per device (default 7)")
ap.add_argument("--dry-run", action="store_true", help="Change nothing")
args = ap.parse_args()
user, password = credentials()
devices = load_inventory(args.inventory)
if not args.dry_run:
os.makedirs(args.out, exist_ok=True)
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
worst, ok = 0, 0
for dev in devices:
host = dev["host"]
print("=" * 72)
print(host)
print("=" * 72)
name = "%s_%s.ucs" % (host, stamp)
dest = os.path.join(args.out, name)
try:
bip = BigIP(host, user, password,
verify=dev.get("verify_ssl", True),
timeout=dev.get("timeout", 300))
ver = bip.get("/tm/sys/version")
print(" version query ok")
del ver
if not create_ucs(bip, name, args.dry_run):
print()
continue
print(" created on device: %s" % name)
size = download_ucs(bip, name, dest)
print(" downloaded: %s (%.1f MB)" % (dest, size / 1048576.0))
if size < 1024:
print(" ERROR: archive is suspiciously small. Treat as FAILED.")
worst = 2
continue
n = prune(args.out, host, args.keep)
if n:
print(" pruned %d old archive(s), keeping %d" % (n, args.keep))
ok += 1
except requests.exceptions.SSLError as exc:
print(" ERROR: TLS failed: %s" % exc)
worst = 2
except requests.exceptions.RequestException as exc:
print(" ERROR: %s" % exc)
worst = 2
except OSError as exc:
print(" ERROR: could not write %s: %s" % (dest, exc))
worst = 2
print()
print("%d of %d device(s) backed up." % (ok, len(devices)))
if ok == 0 and devices:
return 2
return worst
if __name__ == "__main__":
sys.exit(main())
--inventorytextrequiredYAML inventory of BIG-IPs. Credentials never go in it
--outtextLocal directory to store the archives. Default ./f5-backups
--keepnumberHow many archives to keep per device before pruning the oldest. Default 7
--dry-runflagShow what would happen and change nothing
What This Solves
Your BIG-IP backups are UCS archives, and they are sitting on the BIG-IP. That is not a backup. The scenario where you need a UCS is the one where the device is gone: hardware failure, a bad upgrade, a corrupted volume. A file stored on the device you lost is a file you lost. This script creates the archive and pulls it OFF the device, on a schedule, with retention. One thing it cannot fix, and you should know it: a UCS is tied to the device it came from, licence and master key included. Restoring one onto different hardware needs extra steps. If your plan is to rebuild on new hardware, you also want an SCF. Most estates only have UCS and discover this during the rebuild.
Is It Safe to Run
Yes, with one caveat about load. - It creates a UCS archive on the device (a write, but a routine one) and downloads it. It changes no configuration. - Creating a UCS consumes CPU and disk on the BIG-IP for the duration. On a busy production unit, schedule it outside peak. - It deletes LOCAL copies beyond --keep. It never deletes anything on the BIG-IP. API calls: POST to create the UCS, GET to download it in chunks.
Before You Start
- Python 3.8 or newer - pip install requests PyYAML - A BIG-IP account that can create a UCS (admin-level in practice) - Disk space locally: a UCS runs from tens of MB to several GB - An inventory file (see REFERENCE below)
Your First Run
1. Create devices.yaml: devices: - host: ltm01.example.com 2. Set the credentials: export F5_USER=svc_automation export F5_PASS='...' 3. Run it: python3 f5_ucs_backup.py --inventory devices.yaml --out ./f5-backups The first run on a large device can take several minutes. That is the archive being built, not the script hanging.
What the Output Means
======================================================================== ltm01.example.com ======================================================================== version query ok created on device: ltm01.example.com_20261015T0300Z.ucs downloaded: ./f5-backups/ltm01.example.com_20261015T0300Z.ucs (412.8 MB) pruned 1 old archive(s), keeping 7 1 of 1 device(s) backed up. The size matters. An archive under 1 KB is reported as an ERROR and treated as a failed backup, because a UCS that small did not capture anything. Filenames are host plus a UTC timestamp, so they sort chronologically and retention is just keeping the newest N per device.
Exit Codes for Cron and CI
0 Every device backed up. 2 At least one device failed, or no device succeeded. Use this in cron. A backup job that cannot fail loudly is a backup job you will discover has not run when you need it. 0 3 * * * /usr/bin/python3 /opt/f5/f5_ucs_backup.py \ --inventory /opt/f5/devices.yaml --out /backup/f5 || \ mail -s "F5 backup FAILED" ops@example.com < /dev/null
Every Flag
The inventory file Passed with --inventory devices.yaml. Credentials NEVER go in it. defaults: # optional, applied to every device below verify_ssl: true timeout: 60 devices: - host: ltm01.example.com - host: 10.0.0.10 verify_ssl: false # per-device override, self-signed cert timeout: 300 host REQUIRED. Management IP or DNS name of the BIG-IP. verify_ssl Verify the management certificate. Default true. Set false ONLY for a known self-signed management certificate. timeout Seconds per HTTP request. Credentials - environment only F5_USER username (required) F5_PASS password (required) Linux / macOS: export F5_USER=svc_automation export F5_PASS='...' PowerShell: $env:F5_USER='svc_automation' $env:F5_PASS='...' CMD: set F5_USER=svc_automation set F5_PASS=... Environment rather than arguments on purpose: an argument is visible in ps auxww while the process runs, and lands in shell history. The account needs a role with REST access. Operator is enough for the read-only work; changing pool member state needs more. Arguments --inventory PATH REQUIRED. YAML inventory of BIG-IPs. --out DIR Local directory for the archives. Default ./f5-backups --keep N Archives to keep per device. Default 7. --dry-run Change nothing.
When Something Fails
ERROR: F5_USER and F5_PASS must be set in the environment You exported them in a different shell, or in a shell that has since closed. Export them again in this one. ERROR: <host> rejected the credentials (401) Wrong password, or the account has no REST access. Confirm with: curl -ku USER:PASS https://HOST/mgmt/tm/sys/version ERROR: TLS failed The management certificate is self-signed and verify_ssl is true. Set verify_ssl: false for that device in the inventory, and only for that device. Do not turn it off globally out of convenience. ERROR: <host> unreachable Port 443 to the management address is blocked, or the management interface is on a network you cannot reach from here. 404 on a pool or object name Names are case sensitive and include the partition. Use the full path, /Common/pool_app, not pool_app. The download is slow or times out A multi-gigabyte UCS over a slow management link takes time. Raise timeout in the inventory for that device. 403 when creating the UCS The account can read but not create a UCS. This operation needs more than the Operator role. Archive reported as suspiciously small Creation failed on the device even though the API returned success. Check /var/log/ltm on the BIG-IP and the free space in /var.
Agents
/agents/f5-expertTags
f5big-ipucsbackupicontrol-restdisaster-recoveryautomation