Work in progress — this build log is still being written.
session log
Building a BTP Entitlements & Usage Tool, One Bug at a Time
A working log of an interactive command-line tool built around the SAP btp CLI —
from a first working script to a self-correcting report generator that only trusts what it can prove.
btp_entitlement_tool.py
$ python3 btp_entitlement_tool.py
======================================================================
SAP BTP - Entitlements & Usage Tool
======================================================================[+] Proxy set for this session: http://proxy.internal.example:8080
[+] Found btp CLI: btp version 2.x.x
Available subaccounts
----------------------------------------------------------------------
[1] Sandbox region=eu10 state=OK id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
[2] Integration region=eu10 state=OK id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
[3] Production region=eu10 state=OK id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
----------------------------------------------------------------------
Select a subaccount by number (or 'q' to quit): 1
[1] Compare entitlements usage (assigned vs. actually used)
[2] List subscriptions
[3] Check connected Cloud Connectors (Location ID)
[c] Edit configuration (proxy / URLs / btp path)
[b] Back to subaccount list
[q] Quit
Choose an option:
The brief was simple at first: a menu-driven CLI script that logs into SAP BTP,
lists subaccounts, and reports which entitlements are actually being used versus just sitting
assigned. It grew into something more interesting — mostly because the first few answers it gave
were confidently wrong, and getting it right meant refusing to guess.
All subaccount names, IDs, hostnames, and organization details below are placeholders.
00 · Prerequisites
What actually has to be on the machine
Two things, and only two: Python 3.6 or newer (already installed on most
SAP-adjacent Linux/Windows jump hosts), and the official SAP btp
CLI, downloaded from SAP's Development Tools page and placed on PATH
(or pointed to directly during setup — see Stage 02).
Everything else is deliberately the Python standard library — json,
csv, subprocess, urllib, ssl,
getpass, pathlib. No pip install, no virtual
environment, no internet access required beyond what btp login itself needs.
That was a deliberate call: this runs on locked-down jump hosts exactly as it runs anywhere
else.
# 1. Confirm Python is available (3.6+)
$ python3 --version
# 2. Install the SAP btp CLI, then confirm it's reachable
$ btp --version
# 3. Run the tool - no build step, no dependencies to install
$ python3 btp_entitlement_tool.py
01 · Scaffolding
A menu, wrapped around the real CLI
Rather than reimplementing SAP BTP's account APIs from scratch, the tool shells out to the
official btp CLI and wraps it in an interactive menu — proxy environment variables
set up front, login delegated straight to btp login (so SSO and password flows both
just work), subaccounts fetched as JSON and presented as a numbered list, then a per-subaccount
options menu. First and only feature to start: compare entitlements usage and write out an
HTML + CSV report.
# proxy applied to this process, and everything it launchesdef set_proxy_env(cfg):
proxy_url = f"http://{cfg['proxy_host']}:{cfg['proxy_port']}"for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
os.environ[var] = proxy_url
# login is never reimplemented - just handed straight to the real CLI,# so the user's own terminal handles SSO / password prompts directlydef btp_login(cfg):
return subprocess.run([cfg["btp_executable"], "login", "--url", cfg["btp_cli_url"]]).returncode == 0
02 · Configuration
Nothing hardcoded, nothing asked twice
Proxy host, cockpit URL, the CLI login URL, and the path to the btp executable
all moved out of constants and into a first-run setup wizard. It auto-detects the CLI on
PATH where it can, asks once for everything else, and saves it to a local config
file — so every run after the first is just [U]se this configuration and go.
Editable anytime from the subaccount menu without restarting.
Every field the wizard asks for, and what it's used for:
Field
Asked
Used for
proxy_enabled
Y/N
Whether to set proxy env vars at all
proxy_host / proxy_port
if proxy enabled
Corporate proxy for reaching SAP BTP
persist_proxy_os
Y/N
Also write proxy to shell profile / Windows user env
btp_cockpit_url
free text
Reference only — not used for login
btp_cli_url
free text
Actual target for btp login (default cli.btp.cloud.sap)
btp_executable
auto-detect + confirm
Path to the btp binary
output_dir
free text
Where generated reports are written
cloud_connectors
optional, repeatable
See Stage 09 — host/port/username only, never the password
Saved to ~/.btp_entitlement_tool/config.json — a plain JSON file, safe to inspect
or hand-edit, with no secrets in it.
03 · First real run
Not every box runs the newest Python
The first live run failed before doing anything useful:
TypeError: __init__() got an unexpected keyword argument 'capture_output'
The target host was on Python 3.6 — capture_output and text=True on
subprocess.run() didn't exist until 3.7. Swapped every call to the
3.6-compatible form and swept the rest of the file for other newer-Python-only syntax before
calling it fixed.
Next run: 41 entitlements found, 0 in use, 0 not in use. No error — just a
confidently wrong answer. The classification logic had been written against guessed field
names (amount, remainingAmount) that didn't match this
btp CLI version's actual output, compounded by a real structural fact: at
subaccount level, entitlements often don't carry quota-consumption data at all — that's more
of a directory-level concept.
fix
Added a self-diagnosing fallback: when classification comes up empty, the tool dumps the
real field names it received instead of failing silently. That surfaced the actual schema —
service, plan, quota, unlimited,
resources — and the whole usage model got rebuilt around it: a non-empty
resources array or an active subscription both count as concrete proof of use;
anything unlimited with neither signal is honestly labeled rather than guessed at.
In useNot usedAlways available
# the real schema, once it stopped being guessed at:
{
"service": "apimanagement-apiportal",
"plan": "apim-as-route-service",
"quota": 2000000000,
"unlimited": true,
"serviceCategory": "ELASTIC_SERVICE",
"resources": []
}
05 · The silent failure
A report that stopped writing itself — with no error
A later edit introduced something worse than a crash: the HTML report simply stopped being
written, no traceback, no warning. Guessing wasn't going to find it — instrumented debug
prints did. A leftover return statement, dropped in by an earlier edit, was
sitting directly inside the row-building loop:
for r in rows:
pct = 0 if pct is None else max(0, min(100, pct))
color = "#2e7d32" if pct > 0 else "#b0bec5"return (f'<div class="bar-track">...</div>')← exits the whole function
It exited the entire function on the very first row, before the file was ever written. Fixed
by rewriting the function cleanly, then verifying the fix the same way the bug was found —
not by reading the code again, but by actually writing a file to disk and rendering it through
a headless browser to confirm, byte for byte, that it existed.
06 · Readability pass
Written for people who've never seen a terminal
The technical column dump worked for debugging, not for handing to someone outside BASIS. The
report was rebuilt around a plain-language intro, a small SVG donut chart (after CSS
conic-gradient quietly failed to render in one test environment — SVG renders
everywhere), and a "what this means" column that turns every status into one sentence
instead of a bare badge.
07 · Naming
Technical IDs aren't names
Cockpit shows "Audit Log Viewer Service." The entitlements API only ever returns
auditlog-viewer. Rather than hand-maintain a translation table doomed to go stale,
service names now resolve through three tiers: a live lookup against the subaccount's own
service marketplace first (authoritative — it matches exactly what that landscape calls
things), a small table of well-known SAP service names as a fallback, and generic
title-casing as a last resort so even an unmapped ID still reads cleanly instead of showing
up as a raw slug.
08 · A third signal
An environment isn't a subscription
Cloud Foundry kept showing up as "Not used" — even fully provisioned — because environments
don't carry quota or subscription data the way applications do. Added a third independent
signal: a live, healthy entry in the subaccount's provisioned environments now counts as proof
of use on its own. That pushed the whole classification model to be rewritten around a list of
independently-checkable reasons, instead of hand-enumerating every possible combination of
signals as its own hardcoded status string.
09 · Cloud Connectors
Finding what BTP itself won't tell you
Last ask: report which Cloud Connector(s) a subaccount is attached to, and its Location ID.
No BTP-side API publishes that — it's cockpit-UI-only. The Cloud Connector's own local
monitoring API does expose it, though, so that became an optional integration: register a
connector's host, port, and username in configuration, and the tool queries it directly,
matching by subaccount ID to report back the Location ID and tunnel state.
What's needed to turn this on, added during setup:
Field
Example
Notes
host
scc.internal.example
Network access to this host required from wherever the script runs
port
8443
The connector's admin/monitoring port
username
monitoring_user
An account with access to the connector's admin UI
verify_ssl
false
Connectors are almost always self-signed internally
password
—
Never stored — prompted for at runtime only
# talks directly to the Cloud Connector's own API - unrelated to the btp CLI
GET https://{host}:{port}/api/monitoring/subaccounts
Authorization: Basic base64(username:password)
# matched against this subaccount's GUID in the response:
{ "subaccounts": [
{ "subaccount": "<guid>", "locationID": "TG",
"tunnel": { "state": "Connected" } }
] }
by design
The password is never written to disk — only host, port, and username are saved. The
password itself is asked for once per run and kept in memory only for that session.
Everything the script can be configured with, in one place
All of it lives in a single JSON file at ~/.btp_entitlement_tool/config.json,
editable by hand or through the [c] menu option — no environment variables to
remember, no flags to pass on every run.
Almost every stage above followed the same shape: something looked correct until it was tested
against real output, and every fix came from evidence — a raw JSON dump, an instrumented print
statement, an actual rendered file — rather than a second guess layered on the first. The tool
still only has three menu options. The scaffolding was built to make a fourth one cheap.
Appendix — the full script
Everything above, assembled. Copy it out, save it as btp_entitlement_tool.py,
and it runs as-is — no dependencies beyond the Python standard library and the SAP
btp CLI on PATH (see Stage 00). Proxy host, cockpit URL, and Cloud
Connector details are all supplied by you on first run — there is nothing environment-specific
left in the file itself.
btp_entitlement_tool.py
#!/usr/bin/env python3
"""
BTP Entitlements & Usage Tool
==============================
An interactive, menu-driven command-line tool for SAP BTP administration,
built around the official `btp` CLI.
Current features:
0) First-run bootstrap wizard - asks for proxy, BTP URLs, and the btp
executable location, then saves them so you're not asked again
1) Login to SAP BTP (delegates to `btp login`, supports SSO / password)
2) List and select a subaccount from your global account
3) Compare entitlements vs. actual usage for the selected subaccount
and generate an HTML + CSV visual report
Designed to be extended with more menu options over time (see MENU_OPTIONS
and the OPTION_HANDLERS dict near the bottom).
Requirements:
- Python 3.8+
- SAP BTP CLI ("btp") installed - the wizard will help you locate it
https://tools.hana.ondemand.com/#cloud (or your internal SAP download mirror)
Notes on the BTP CLI:
- `btp login` targets a *CLI server URL* (default: https://cli.btp.cloud.sap/),
which is NOT the same as the BTP Cockpit URL. If your environment (e.g. a
RISE Private Cloud / restricted landscape) uses a custom CLI server URL, the
bootstrap wizard lets you set it.
- There is no client-credentials / headless login for the btp CLI. It always
prompts for user/password or SSO - which is exactly what this script relies on.
- All list commands are called with --format json for reliable, stable parsing.
"""
import csv
import datetime
import getpass
import json
import math
import os
import re
import shutil
import ssl
import subprocess
import sys
import urllib.error
import urllib.request
import base64
from pathlib import Path
# --------------------------------------------------------------------------
# Config storage location
# --------------------------------------------------------------------------
CONFIG_DIR = Path.home() / ".btp_entitlement_tool"
CONFIG_FILE = CONFIG_DIR / "config.json"
# Fallback defaults used only to pre-fill the bootstrap wizard prompts -
# nothing here is used directly at runtime once a config exists.
DEFAULT_CLI_URL = "https://cli.btp.cloud.sap/"
# --------------------------------------------------------------------------
# Small helpers
# --------------------------------------------------------------------------
def clear_screen():
os.system("cls" if os.name == "nt" else "clear")
def hr(char="-", width=70):
print(char * width)
def pause():
input("\nPress ENTER to continue...")
def fail(msg):
print(f"\n[ERROR] {msg}")
def info(msg):
print(f"[i] {msg}")
def ok(msg):
print(f"[+] {msg}")
def ask(prompt, default=None, required=False):
"""Prompt for free text, showing a default in brackets if one is given.
Pressing ENTER accepts the default. If required and no default, keeps
asking until non-empty input is given."""
while True:
suffix = f" [{default}]" if default not in (None, "") else ""
raw = input(f"{prompt}{suffix}: ").strip()
if not raw and default not in (None, ""):
return default
if not raw and required and default in (None, ""):
print(" This value is required.")
continue
return raw
def ask_yes_no(prompt, default_yes=True):
suffix = "[Y/n]" if default_yes else "[y/N]"
raw = input(f"{prompt} {suffix}: ").strip().lower()
if not raw:
return default_yes
return raw == "y"
# --------------------------------------------------------------------------
# 0. Bootstrap configuration - asked interactively, persisted to disk
# --------------------------------------------------------------------------
def default_config():
return {
"proxy_enabled": False,
"proxy_host": "",
"proxy_port": "",
"persist_proxy_os": False,
"btp_cockpit_url": "",
"btp_cli_url": DEFAULT_CLI_URL,
"btp_executable": "",
"output_dir": str(Path.cwd() / "btp_reports"),
# Cloud Connector definitions used to check which connector(s) a
# subaccount is attached to, and its Location ID (see
# check_cloud_connectors()). Passwords are deliberately NOT stored
# here - only host/port/username/verify_ssl are persisted; the
# password is asked for at runtime and kept in memory only.
"cloud_connectors": [],
}
def load_config():
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = json.load(f)
merged = default_config()
merged.update(cfg)
return merged
except (json.JSONDecodeError, OSError) as e:
fail(f"Could not read existing config ({e}). Starting fresh.")
return None
def save_config(cfg):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
ok(f"Configuration saved to {CONFIG_FILE}")
def print_config_summary(cfg):
hr()
print("Current configuration")
hr()
if cfg["proxy_enabled"]:
print(f" Proxy : {cfg['proxy_host']}:{cfg['proxy_port']}")
else:
print(" Proxy : disabled")
print(f" BTP Cockpit URL : {cfg['btp_cockpit_url'] or '(not set)'}")
print(f" BTP CLI login URL : {cfg['btp_cli_url']}")
print(f" btp executable : {cfg['btp_executable'] or '(auto-detect on PATH)'}")
print(f" Report output folder: {cfg['output_dir']}")
connectors = cfg.get("cloud_connectors") or []
if connectors:
print(f" Cloud Connectors : {len(connectors)} configured")
for cc in connectors:
print(f" - {cc['username']}@{cc['host']}:{cc.get('port', 8443)}")
else:
print(" Cloud Connectors : none configured")
hr()
def detect_btp_executable():
"""Try to find the btp CLI on PATH. Returns the resolved path or None."""
found = shutil.which("btp")
return found
def configure_cloud_connectors(existing):
"""Interactively manage the list of Cloud Connectors to check against
when generating reports. Only host/port/username/verify_ssl are stored -
the password is never written to disk (see check_cloud_connectors)."""
connectors = [dict(cc) for cc in (existing or [])]
if connectors:
print("Configured Cloud Connector(s):")
for cc in connectors:
print(f" - {cc['username']}@{cc['host']}:{cc.get('port', 8443)}")
if not ask_yes_no("Keep these?", default_yes=True):
connectors = []
want_more = ask_yes_no(
"\nAdd a Cloud Connector? (used to report which Location ID this subaccount "
"connects through - requires network access to the connector's admin port, "
"usually 8443)",
default_yes=not connectors,
)
while want_more:
host = ask("Cloud Connector hostname or IP", required=True)
port = ask("Cloud Connector admin port", default="8443")
username = ask("Cloud Connector admin username", required=True)
verify_ssl = ask_yes_no(
"Verify the Cloud Connector's SSL certificate? (it's usually self-signed - No is typical)",
default_yes=False,
)
connectors.append({"host": host, "port": port, "username": username, "verify_ssl": verify_ssl})
info("Note: the password is never stored - you'll be prompted for it when needed, once per run.")
want_more = ask_yes_no("Add another Cloud Connector?", default_yes=False)
return connectors
def run_bootstrap_wizard(existing=None):
"""Interactively collect everything needed to run the tool, pre-filling
with any existing values so re-running the wizard is just 'press ENTER
to keep, or type a new value'."""
cfg = dict(existing) if existing else default_config()
clear_screen()
print("=" * 70)
print(" First-time setup / configuration")
print("=" * 70)
print("These settings are saved locally and reused on future runs.")
print(f"(stored at {CONFIG_FILE})\n")
# --- Proxy ---
use_proxy = ask_yes_no("Do you need to go through a corporate proxy to reach SAP BTP?",
default_yes=cfg.get("proxy_enabled", False))
cfg["proxy_enabled"] = use_proxy
if use_proxy:
cfg["proxy_host"] = ask("Proxy host", default=cfg.get("proxy_host") or None, required=True)
cfg["proxy_port"] = ask("Proxy port", default=cfg.get("proxy_port") or None, required=True)
cfg["persist_proxy_os"] = ask_yes_no(
"Also persist this proxy at OS/user level (setx / shell profile) on every run?",
default_yes=cfg.get("persist_proxy_os", False),
)
else:
cfg["proxy_host"] = ""
cfg["proxy_port"] = ""
cfg["persist_proxy_os"] = False
# --- URLs ---
print()
cfg["btp_cockpit_url"] = ask(
"SAP BTP Cockpit URL (for your reference, not used for login)",
default=cfg.get("btp_cockpit_url") or "https://<your-region>.cockpit.btp.cloud.sap/",
)
cfg["btp_cli_url"] = ask(
"BTP CLI server URL for 'btp login' (leave default unless your operator gave you a custom one)",
default=cfg.get("btp_cli_url") or DEFAULT_CLI_URL,
)
# --- btp executable ---
print()
detected = detect_btp_executable()
if detected:
info(f"Found btp CLI on PATH: {detected}")
use_detected = ask_yes_no("Use this one?", default_yes=True)
cfg["btp_executable"] = detected if use_detected else ask(
"Full path to the btp executable", default=cfg.get("btp_executable") or None, required=True
)
else:
info("Could not auto-detect the btp CLI on your PATH.")
info("Download it from: https://tools.hana.ondemand.com/#cloud")
cfg["btp_executable"] = ask(
"Full path to the btp executable (or just 'btp' if you'll add it to PATH later)",
default=cfg.get("btp_executable") or "btp",
required=True,
)
# --- output dir ---
print()
cfg["output_dir"] = ask(
"Folder to save generated reports into",
default=cfg.get("output_dir") or str(Path.cwd() / "btp_reports"),
)
# --- Cloud Connectors ---
print()
cfg["cloud_connectors"] = configure_cloud_connectors(cfg.get("cloud_connectors"))
print()
print_config_summary(cfg)
if ask_yes_no("Save this configuration?", default_yes=True):
save_config(cfg)
else:
info("Not saved - these settings will only be used for this run.")
pause()
return cfg
def get_config():
"""Load config from disk, or run the bootstrap wizard if this is the
first run / the file is missing. Also offers to re-run the wizard even
when a config already exists."""
cfg = load_config()
if cfg is None:
return run_bootstrap_wizard()
clear_screen()
print_config_summary(cfg)
choice = ask("[U]se this configuration, [E]dit it, or [R]eset to a fresh wizard?",
default="U").strip().lower()
if choice.startswith("e"):
return run_bootstrap_wizard(existing=cfg)
if choice.startswith("r"):
return run_bootstrap_wizard(existing=None)
return cfg
# --------------------------------------------------------------------------
# 1. Proxy configuration
# --------------------------------------------------------------------------
def set_proxy_env(cfg):
if not cfg["proxy_enabled"]:
info("Proxy disabled in configuration - skipping.")
return
proxy_url = f"http://{cfg['proxy_host']}:{cfg['proxy_port']}"
for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
os.environ[var] = proxy_url
ok(f"Proxy set for this session: {proxy_url}")
if cfg.get("persist_proxy_os"):
_persist_proxy(proxy_url)
def _persist_proxy(proxy_url):
"""Persist the proxy setting at the OS/user level. Only called when the
user opted in during the bootstrap wizard."""
if os.name == "nt":
for var in ("HTTP_PROXY", "HTTPS_PROXY"):
subprocess.run(["setx", var, proxy_url], check=False)
ok("Persisted HTTP_PROXY / HTTPS_PROXY as Windows user environment variables.")
info("Open a NEW terminal window for it to take effect outside this script.")
else:
profile = Path.home() / (".bashrc" if os.environ.get("SHELL", "").endswith("bash") else ".profile")
lines = [
f'\n# Added by btp_entitlement_tool.py on {datetime.datetime.now().isoformat()}\n',
f'export HTTP_PROXY="{proxy_url}"\n',
f'export HTTPS_PROXY="{proxy_url}"\n',
f'export http_proxy="{proxy_url}"\n',
f'export https_proxy="{proxy_url}"\n',
]
with open(profile, "a") as f:
f.writelines(lines)
ok(f"Appended proxy exports to {profile}")
info(f"Run 'source {profile}' or open a new shell for it to take effect elsewhere.")
# --------------------------------------------------------------------------
# 2. btp CLI wrapper
# --------------------------------------------------------------------------
def check_btp_cli(cfg):
btp_path = cfg["btp_executable"] or "btp"
resolved = shutil.which(btp_path) or (btp_path if Path(btp_path).exists() else None)
if resolved is None:
fail(f"Could not find/execute the btp CLI at '{btp_path}'.")
info("Re-run the tool and choose to edit the configuration to fix the path,")
info("or download the CLI from: https://tools.hana.ondemand.com/#cloud")
sys.exit(1)
result = subprocess.run([btp_path, "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
ok(f"Found btp CLI: {(result.stdout.strip() or result.stderr.strip())}")
def btp_json(cfg, args):
"""Run a `btp --format json ...` command and return parsed JSON.
Returns None on failure (and prints the error)."""
cmd = [cfg["btp_executable"], "--format", "json"] + args
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
if result.returncode != 0:
fail(f"Command failed: {' '.join(cmd)}")
print(result.stderr.strip() or result.stdout.strip())
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
fail("Could not parse JSON output from btp CLI. Raw output was:")
print(result.stdout)
return None
def btp_interactive(cfg, args):
"""Run a `btp ...` command letting the user interact directly with it
(used for login, where prompts/SSO flow need to reach the terminal)."""
cmd = [cfg["btp_executable"]] + args
return subprocess.run(cmd).returncode
def btp_login(cfg):
print(f"\nBTP Cockpit (reference only): {cfg['btp_cockpit_url']}")
print(f"Logging in via CLI server: {cfg['btp_cli_url']}\n")
mode = input("Login with (1) username/password or (2) SSO browser login? [1/2]: ").strip()
if mode == "2":
rc = btp_interactive(cfg, ["login", "--url", cfg["btp_cli_url"], "--sso"])
else:
rc = btp_interactive(cfg, ["login", "--url", cfg["btp_cli_url"]])
if rc != 0:
fail("Login did not complete successfully.")
return False
ok("Login successful.")
return True
# --------------------------------------------------------------------------
# 3. Subaccount selection
# --------------------------------------------------------------------------
def list_subaccounts(cfg):
data = btp_json(cfg, ["list", "accounts/subaccount"])
if data is None:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
# Try the key names we've seen across btp CLI versions first.
for key in ("subaccounts", "values", "subAccounts", "items"):
value = data.get(key)
if isinstance(value, list):
return value
# Fall back to any list-of-dicts found anywhere in the payload -
# covers key names we haven't seen yet without failing silently.
for key, value in data.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
info(f"Note: parsed subaccounts from unexpected JSON key '{key}'.")
return value
# Nothing usable found - surface the raw shape instead of silently
# returning an empty list, so this is easy to diagnose.
fail("Got a JSON response from btp CLI, but couldn't find a subaccount list inside it.")
info(f"Top-level keys returned: {list(data.keys())}")
info("Run this manually to inspect the full output:")
info(f" {cfg['btp_executable']} --format json list accounts/subaccount")
return []
fail(f"Unexpected JSON shape from btp CLI: {type(data).__name__}")
return []
def choose_subaccount(cfg):
subaccounts = list_subaccounts(cfg)
if not subaccounts:
fail("No subaccounts found (or you don't have access to any).")
return None
clear_screen()
print("Available subaccounts")
hr()
for idx, sa in enumerate(subaccounts, start=1):
name = sa.get("displayName") or sa.get("name") or "?"
region = sa.get("region", "?")
sub_id = sa.get("guid") or sa.get("id") or "?"
state = sa.get("state", "?")
print(f"[{idx}] {name:<30} region={region:<8} state={state:<10} id={sub_id}")
hr()
choice = input("\nSelect a subaccount by number (or 'q' to quit): ").strip()
if choice.lower() == "q":
return None
if not choice.isdigit() or not (1 <= int(choice) <= len(subaccounts)):
fail("Invalid selection.")
return None
selected = subaccounts[int(choice) - 1]
sub_id = selected.get("guid") or selected.get("id")
name = selected.get("displayName") or selected.get("name")
info(f"Targeting subaccount '{name}' ({sub_id})...")
rc = subprocess.run([cfg["btp_executable"], "target", "--subaccount", sub_id],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
if rc.returncode != 0:
fail(f"Could not target subaccount: {rc.stderr.strip()}")
return None
ok("Subaccount targeted.")
return {"id": sub_id, "name": name}
# --------------------------------------------------------------------------
# 4. Option 1: Compare entitlements usage
# --------------------------------------------------------------------------
def _first(d, *keys, default=None):
"""Return the first present, non-None value among a list of candidate keys.
Used because entitlement JSON field names can vary slightly by CLI version."""
for k in keys:
if k in d and d[k] is not None:
return d[k]
return default
def get_entitlements(cfg, subaccount_id):
data = btp_json(cfg, ["list", "accounts/entitlement", "--subaccount", subaccount_id])
if data is None:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("entitlements", "values", "items"):
value = data.get(key)
if isinstance(value, list):
return value
for key, value in data.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
info(f"Note: parsed entitlements from unexpected JSON key '{key}'.")
return value
fail("Got a JSON response from btp CLI, but couldn't find an entitlements list inside it.")
info(f"Top-level keys returned: {list(data.keys())}")
info("Run this manually to inspect the full output:")
info(f" {cfg['btp_executable']} --format json list accounts/entitlement --subaccount {subaccount_id}")
return []
fail(f"Unexpected JSON shape from btp CLI: {type(data).__name__}")
return []
def get_subscriptions(cfg, subaccount_id):
"""Fetch subscriptions for a subaccount, used as a secondary 'actually in
use' signal (see analyze_entitlements). Parsing is defensive for the same
reason as get_entitlements/list_subaccounts - key names vary by CLI
version."""
data = btp_json(cfg, ["list", "accounts/subscription", "--subaccount", subaccount_id])
if data is None:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("subscriptions", "values", "items"):
value = data.get(key)
if isinstance(value, list):
return value
for key, value in data.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
return value
return []
def get_environment_instances(cfg, subaccount_id):
"""Fetch provisioned environments (Cloud Foundry, Kyma, ABAP, Neo, ...)
for a subaccount. A provisioned environment is concrete evidence that
its corresponding entitlement is in use - this is what lets the report
correctly show e.g. Cloud Foundry as 'in use' when a CF org has been
created, even though it has no quota/subscription data of its own."""
data = btp_json(cfg, ["list", "accounts/environment-instance", "--subaccount", subaccount_id])
if data is None:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("environmentInstances", "values", "items"):
value = data.get(key)
if isinstance(value, list):
return value
for key, value in data.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
return value
return []
# In-memory only cache of Cloud Connector passwords for this run - never
# written to disk. Keyed by "username@host:port".
_CC_PASSWORD_CACHE = {}
def fetch_cloud_connector_subaccounts(host, port, username, password, verify_ssl=False, timeout=10):
"""Query a Cloud Connector's own monitoring API directly for its list of
connected BTP subaccounts (https://host:port/api/monitoring/subaccounts).
This is unrelated to the btp CLI or SAP BTP's own APIs - it talks
straight to the on-premise Cloud Connector, which is the only place this
information is exposed (SAP hasn't published a BTP-side API for it).
Returns (data, error): data is the parsed JSON dict on success (None on
failure), error is a human-readable message (None on success)."""
url = f"https://{host}:{port}/api/monitoring/subaccounts"
req = urllib.request.Request(url)
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
req.add_header("Authorization", f"Basic {token}")
req.add_header("Accept", "application/json")
ctx = ssl.create_default_context()
if not verify_ssl:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
body = resp.read().decode("utf-8")
return json.loads(body), None
except urllib.error.HTTPError as e:
if e.code == 401:
return None, "Authentication failed - check the username/password."
return None, f"HTTP error {e.code}: {e.reason}"
except urllib.error.URLError as e:
return None, f"Could not connect to {host}:{port} ({e.reason})."
except ssl.SSLError as e:
return None, f"SSL error connecting to {host}:{port} ({e})."
except (json.JSONDecodeError, ValueError):
return None, "Got a response, but it wasn't valid JSON - check the host/port point to a Cloud Connector."
except Exception as e:
return None, f"Unexpected error: {e}"
def check_cloud_connectors(cfg, subaccount_id):
"""For every Cloud Connector configured in cfg['cloud_connectors'],
query its monitoring API and check whether this subaccount is connected
to it. Prompts for each connector's password once per run (not stored).
Returns a list of result dicts, one per configured connector:
{host, found, location_id, tunnel_state, display_name, connected_since, error}
"""
connectors = cfg.get("cloud_connectors") or []
results = []
for cc in connectors:
host = cc["host"]
port = cc.get("port", 8443)
username = cc["username"]
verify_ssl = cc.get("verify_ssl", False)
label = f"{host}:{port}"
cache_key = f"{username}@{label}"
password = _CC_PASSWORD_CACHE.get(cache_key)
if password is None:
password = getpass.getpass(f"Password for Cloud Connector {username}@{label}: ")
_CC_PASSWORD_CACHE[cache_key] = password
data, error = fetch_cloud_connector_subaccounts(host, port, username, password, verify_ssl)
result = {
"host": label, "found": False, "location_id": None,
"tunnel_state": None, "display_name": None, "connected_since": None,
"error": error,
}
if data:
subs = data.get("subaccounts") if isinstance(data, dict) else None
for s in (subs or []):
if str(s.get("subaccount", "")).strip().lower() == str(subaccount_id).strip().lower():
tunnel = s.get("tunnel") or {}
result["found"] = True
loc = s.get("locationID")
result["location_id"] = loc if loc else "(default - no Location ID set)"
result["tunnel_state"] = tunnel.get("state")
result["display_name"] = s.get("displayName")
ts = tunnel.get("connectedSinceTimeStamp")
if ts:
result["connected_since"] = datetime.datetime.fromtimestamp(
ts / 1000).strftime("%Y-%m-%d %H:%M:%S")
break
results.append(result)
return results
def get_service_display_names(cfg, subaccount_id):
"""Build a technical-name -> human-readable-name lookup from the
subaccount's own service marketplace (`btp list services/offering`).
This is the authoritative source - it reflects exactly what your BTP
cockpit calls things, rather than a hardcoded guess that can go stale
or not match your landscape's naming. Returns {} if the call fails;
callers should fall back gracefully (see humanize_service_name)."""
data = btp_json(cfg, ["list", "services/offering", "--subaccount", subaccount_id])
mapping = {}
if not data:
return mapping
offerings = data
if isinstance(data, dict):
offerings = None
for key in ("serviceOfferings", "offerings", "values", "items"):
value = data.get(key)
if isinstance(value, list):
offerings = value
break
if offerings is None:
for key, value in data.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
offerings = value
break
if offerings is None:
return mapping
if not isinstance(offerings, list):
return mapping
for o in offerings:
if not isinstance(o, dict):
continue
tech_name = _first(o, "name", "servicename", default=None)
if not tech_name:
continue
display = _first(o, "displayName", "displayname", default=None)
if not display:
# SAP Service Manager convention: often nested under metadata
meta = o.get("metadata")
if isinstance(meta, dict):
display = _first(meta, "displayName", "DisplayName", default=None)
if display:
mapping[str(tech_name).strip().lower()] = str(display).strip()
return mapping
# Small set of common technical IDs whose human names are well known, used
# only as a fallback if the live marketplace lookup above didn't cover an
# entry (e.g. reduced permissions, or an entitlement with no offering, such
# as an environment type).
_KNOWN_SERVICE_NAMES = {
"xsuaa": "Authorization and Trust Management Service",
"cloudfoundry": "Cloud Foundry Environment",
"connectivity": "Connectivity Service",
"destination": "Destination Service",
"html5-apps-repo": "HTML5 Application Repository Service",
"portal": "SAP Build Work Zone",
"auditlog-viewer": "Audit Log Viewer Service",
"auditlog-management": "Audit Log Management Service",
"application-logs": "Application Logging Service",
"alert-notification": "Alert Notification Service",
"application-autoscaler": "Application Autoscaler",
"identity": "Cloud Identity Services",
}
_ACRONYMS = {"sap", "api", "xsuaa", "btp", "hana", "cf", "ai", "ml", "sso",
"idp", "cds", "odata", "ui5", "id", "iam"}
def humanize_service_name(technical_name, display_map=None):
"""Best-effort human-readable name for a service's technical ID:
1) live marketplace lookup (display_map, from get_service_display_names)
2) small known-name fallback table
3) generic humanization (hyphens/underscores -> spaces, title case,
common acronyms upper-cased) - not perfect, but always readable."""
key = str(technical_name).strip().lower()
if display_map and key in display_map:
return display_map[key]
if key in _KNOWN_SERVICE_NAMES:
return _KNOWN_SERVICE_NAMES[key]
words = []
for part in re.split(r"[-_]+", str(technical_name)):
if not part:
continue
if part.lower() == "html5":
words.append("HTML5")
elif part.lower() in _ACRONYMS:
words.append(part.upper())
else:
words.append(part.capitalize())
return " ".join(words) if words else str(technical_name)
def _dump_entitlement_diagnostics(entitlements, cfg):
"""Printed only when we truly can't classify anything (e.g. even 'quota'
and 'resources' are missing) - shows the raw shape so this can be fixed,
and saves a sample to disk for easy sharing."""
print()
fail("Couldn't classify any entitlements - the expected fields weren't found.")
all_keys = set()
for e in entitlements:
if isinstance(e, dict):
all_keys.update(e.keys())
info(f"Keys seen across all {len(entitlements)} entitlement records: {sorted(all_keys)}")
try:
output_dir = Path(cfg["output_dir"])
output_dir.mkdir(parents=True, exist_ok=True)
debug_file = output_dir / "entitlement_raw_sample.json"
with open(debug_file, "w", encoding="utf-8") as f:
json.dump(entitlements[:3], f, indent=2)
info(f"First 3 raw records saved to: {debug_file}")
except OSError:
pass
print()
def analyze_entitlements(entitlements, subscriptions=None, environments=None, service_names=None):
"""Normalize raw entitlement rows and classify usage using three
independent 'actually in use' signals, any of which is enough to mark
an entitlement as used:
1) resources: a non-empty 'resources' array on the entitlement itself
is concrete evidence that something is actually consuming it - the
strongest signal the entitlements API gives us directly.
2) Subscription match: the entitlement's service name matches an
actively subscribed application - useful for multitenant-app
entitlements where 'resources' may stay empty.
3) Environment match: the entitlement's service name matches a
provisioned environment (Cloud Foundry, Kyma, ABAP, Neo, ...) from
`accounts/environment-instance`. Environments like Cloud Foundry
carry no quota/resource/subscription data of their own, so without
this check a provisioned CF org would incorrectly show as unused.
Entitlements with unlimited=true and none of the above signals present
(typically ELASTIC_SERVICE plans, e.g. tooling/foundation services)
generally don't track per-resource consumption at all - these are
labelled 'ELASTIC' rather than guessed at.
service_names: optional {technical_name: display_name} lookup from
get_service_display_names(), used to attach a human-readable name.
Each row's 'status' is one of: 'USED', 'NOT USED', 'ELASTIC', 'UNKNOWN'.
Each row's 'reasons' lists which signal(s) justified a 'USED' status, as
short tags: 'resource', 'subscribed', 'environment'.
"""
subscriptions = subscriptions or []
environments = environments or []
subscribed_names = set()
for s in subscriptions:
state = str(_first(s, "state", default="")).upper()
name = _first(s, "appName", "name", default="")
if name and state in ("SUBSCRIBED", "SUBSCRIBING", "ACTIVE", ""):
# Blank state is included defensively - some CLI versions omit
# 'state' entirely for active subscriptions.
subscribed_names.add(str(name).strip().lower())
environment_types = set()
for env in environments:
state = str(_first(env, "state", default="")).upper()
env_type = _first(env, "environmentType", "environment", default="")
if env_type and state in ("OK", "CREATED", "ENABLED", "ACTIVE", ""):
environment_types.add(str(env_type).strip().lower())
rows = []
for e in entitlements:
service = _first(e, "service", "servicename", "serviceName", "name", default="?")
plan = _first(e, "plan", "planName", "servicePlanName", default="?")
quota = _first(e, "quota", "amount", "assignedAmount", default=None)
unlimited = bool(_first(e, "unlimited", "isUnlimited", default=False))
category = _first(e, "serviceCategory", "category", default="")
resources = _first(e, "resources", default=None)
resource_count = len(resources) if isinstance(resources, list) else 0
service_key = str(service).strip().lower()
display_name = humanize_service_name(service, service_names)
reasons = []
if resource_count > 0:
reasons.append("resource")
if service_key in subscribed_names:
reasons.append("subscribed")
if service_key in environment_types:
reasons.append("environment")
if reasons:
status = "USED"
elif unlimited:
status = "ELASTIC"
elif quota is not None:
status = "NOT USED"
else:
status = "UNKNOWN"
rows.append({
"service": service,
"display_name": display_name,
"plan": plan,
"category": category,
"quota": quota,
"unlimited": unlimited,
"resource_count": resource_count,
"subscribed": "subscribed" in reasons,
"environment_active": "environment" in reasons,
"reasons": reasons,
"status": status,
})
rows.sort(key=lambda r: (r["status"] != "USED", r["display_name"].lower()))
return rows
def render_html_report(subaccount, rows, cc_results, out_path):
generated = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
total = len(rows)
used_count = sum(1 for r in rows if r["status"] == "USED")
unused_count = sum(1 for r in rows if r["status"] == "NOT USED")
other_count = total - used_count - unused_count
FRIENDLY_LABEL = {
"USED": "In use",
"NOT USED": "Not used",
"ELASTIC": "Always available",
"UNKNOWN": "Unknown",
}
BADGE_COLOR = {
"USED": "#2e7d32",
"NOT USED": "#c62828",
"ELASTIC": "#1565c0",
"UNKNOWN": "#757575",
}
# One plain-English sentence per possible reason a row was marked "used",
# plus one for each non-used status. Joined together for the final cell.
REASON_TEXT = {
"resource": "Resources are currently assigned to this and consuming it.",
"subscribed": "There is an active subscription using this service.",
"environment": "This environment (e.g. Cloud Foundry, Kyma, ABAP) is provisioned and enabled in this subaccount.",
}
STATIC_EXPLANATION = {
"NOT USED": "This was assigned to the subaccount, but nothing is currently using it.",
"ELASTIC": "This kind of service is always switched on and doesn't track individual usage, "
"so we can't confirm from this data alone whether it's actually being used.",
"UNKNOWN": "We couldn't tell from the data available - worth checking manually.",
}
def status_badge(status):
color = BADGE_COLOR.get(status, "#757575")
label = FRIENDLY_LABEL.get(status, status)
return f'<span class="badge" style="background:{color};">{label}</span>'
def explanation(row):
if row["status"] == "USED":
return " ".join(REASON_TEXT[r] for r in row["reasons"] if r in REASON_TEXT)
return STATIC_EXPLANATION.get(row["status"], "")
rows_html = ""
for r in rows:
quota_s = "Unlimited" if r["unlimited"] else (r["quota"] if r["quota"] is not None else "-")
details = (f"Technical ID: {r['service']} · Plan: {r['plan']} · "
f"Quota: {quota_s} · Resources assigned: {r['resource_count']}")
rows_html += f"""
<tr>
<td>
<div class="svc-name">{r['display_name']}</div>
<div class="svc-details">{details}</div>
</td>
<td>{status_badge(r['status'])}</td>
<td class="explain">{explanation(r)}</td>
</tr>"""
# Small donut chart as plain SVG (stroke-dasharray trick) - renders
# reliably everywhere, unlike CSS conic-gradient which older browsers /
# PDF renderers don't support.
r = 45
circumference = 2 * math.pi * r
used_frac = used_count / total if total else 0
unused_frac = unused_count / total if total else 0
other_frac = 1 - used_frac - unused_frac if total else 0
def arc(color, frac, offset_frac):
length = circumference * frac
gap = circumference - length
dashoffset = -circumference * offset_frac
return (f'<circle cx="60" cy="60" r="{r}" fill="none" stroke="{color}" '
f'stroke-width="18" stroke-dasharray="{length:.2f} {gap:.2f}" '
f'stroke-dashoffset="{dashoffset:.2f}"/>')
donut_svg = (
'<svg viewBox="0 0 120 120" width="130" height="130">'
'<g transform="rotate(-90 60 60)">'
f'{arc("#90a4ae", 1, 0)}' # base ring (covers "other" segment as background)
f'{arc("#2e7d32", used_frac, 0)}'
f'{arc("#c62828", unused_frac, used_frac)}'
'</g>'
f'<text x="60" y="55" text-anchor="middle" font-size="22" font-weight="700" '
f'fill="#1a1a1a" font-family="-apple-system,Segoe UI,Roboto,Arial,sans-serif">{total}</text>'
f'<text x="60" y="72" text-anchor="middle" font-size="11" fill="#666" '
f'font-family="-apple-system,Segoe UI,Roboto,Arial,sans-serif">services</text>'
'</svg>'
)
# Cloud Connector section - only rendered if any were configured/checked.
cc_section = ""
if cc_results:
cc_rows = ""
for r in cc_results:
if r["error"]:
cc_rows += f"""
<tr>
<td>{r['host']}</td>
<td><span class="badge" style="background:#757575;">Couldn't check</span></td>
<td>-</td>
<td class="explain">{r['error']}</td>
</tr>"""
elif r["found"]:
cc_rows += f"""
<tr>
<td>{r['host']}</td>
<td><span class="badge" style="background:#2e7d32;">Connected</span></td>
<td>{r['location_id']}</td>
<td class="explain">Tunnel state: {r['tunnel_state']}{' · since ' + r['connected_since'] if r['connected_since'] else ''}</td>
</tr>"""
else:
cc_rows += f"""
<tr>
<td>{r['host']}</td>
<td><span class="badge" style="background:#c62828;">Not connected</span></td>
<td>-</td>
<td class="explain">This subaccount is not attached to this Cloud Connector.</td>
</tr>"""
cc_section = f"""
<h2>Connected Cloud Connectors</h2>
<table style="margin-bottom:28px;">
<thead>
<tr>
<th>Cloud Connector</th><th>Status</th><th>Location ID</th><th>Details</th>
</tr>
</thead>
<tbody>
{cc_rows}
</tbody>
</table>
"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BTP Entitlements Report - {subaccount['name']}</title>
<style>
body {{ font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif;
background:#f4f6f8; color:#1a1a1a; margin:0; padding:32px; }}
h1 {{ margin-bottom:4px; font-size:26px; }}
h2 {{ font-size:18px; margin:0 0 12px; }}
.meta {{ color:#555; margin-bottom:20px; }}
.intro {{ background:#fff; border-left:4px solid #1565c0; border-radius:4px; padding:16px 20px;
margin-bottom:24px; font-size:15px; line-height:1.5; max-width:900px; }}
.top {{ display:flex; gap:24px; align-items:center; margin-bottom:28px; flex-wrap:wrap; }}
.donut-wrap {{ flex-shrink:0; }}
.legend {{ display:flex; flex-direction:column; gap:8px; }}
.legend-item {{ display:flex; align-items:center; gap:8px; font-size:14px; }}
.dot {{ width:12px; height:12px; border-radius:50%; flex-shrink:0; }}
.dot.used {{ background:#2e7d32; }}
.dot.unused {{ background:#c62828; }}
.dot.other {{ background:#90a4ae; }}
.legend-item b {{ font-size:16px; }}
table {{ width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.1); }}
th, td {{ padding:12px 16px; text-align:left; border-bottom:1px solid #eee; font-size:14px; vertical-align:top; }}
th {{ background:#263238; color:#fff; text-transform:uppercase; font-size:12px; letter-spacing:.03em; }}
tr:hover {{ background:#fafafa; }}
.svc-name {{ font-weight:600; }}
.svc-details {{ color:#888; font-size:12px; margin-top:2px; }}
.explain {{ color:#444; max-width:420px; }}
.badge {{ color:#fff; padding:4px 10px; border-radius:12px; font-size:12px; white-space:nowrap; font-weight:600; }}
footer {{ margin-top:24px; color:#888; font-size:12px; }}
</style>
</head>
<body>
<h1>BTP Entitlements Usage Report</h1>
<div class="meta">
Subaccount: <strong>{subaccount['name']}</strong> ({subaccount['id']})<br>
Generated: {generated}
</div>
<div class="intro">
This subaccount has been granted {total} SAP BTP services ("entitlements"). This report shows,
for each one, whether it's <strong>actually being used</strong> right now or just sitting assigned
and unused. Services marked <strong>"Not used"</strong> are candidates to review or free up.
</div>
<div class="top">
<div class="donut-wrap">{donut_svg}</div>
<div class="legend">
<div class="legend-item"><span class="dot used"></span><b>{used_count}</b> in use</div>
<div class="legend-item"><span class="dot unused"></span><b>{unused_count}</b> not used</div>
<div class="legend-item"><span class="dot other"></span><b>{other_count}</b> always-available / unclear</div>
</div>
</div>
{cc_section}
<h2>Entitlements</h2>
<table>
<thead>
<tr>
<th>Service</th><th>Is it used?</th><th>What this means</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
<footer>Generated by btp_entitlement_tool.py</footer>
</body>
</html>"""
out_path.write_text(html, encoding="utf-8")
def render_csv_report(rows, out_path):
with open(out_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["Service Name", "Technical ID", "Plan", "Category", "Quota",
"Unlimited", "Resource Count", "Subscribed", "Environment Active",
"Status"])
for r in rows:
writer.writerow([
r["display_name"], r["service"], r["plan"], r["category"],
r["quota"], r["unlimited"], r["resource_count"],
r["subscribed"], r["environment_active"], r["status"],
])
def option_compare_entitlements(cfg, subaccount):
info(f"Fetching entitlements for subaccount '{subaccount['name']}'...")
entitlements = get_entitlements(cfg, subaccount["id"])
if not entitlements:
fail("No entitlements returned (or the call failed - see message above).")
pause()
return
info("Fetching subscriptions (used as an 'actually in use' signal)...")
subscriptions = get_subscriptions(cfg, subaccount["id"])
info("Fetching provisioned environments - Cloud Foundry, Kyma, ABAP, Neo (another 'in use' signal)...")
environments = get_environment_instances(cfg, subaccount["id"])
info("Fetching service marketplace (for human-readable service names)...")
service_names = get_service_display_names(cfg, subaccount["id"])
rows = analyze_entitlements(entitlements, subscriptions, environments, service_names)
used = sum(1 for r in rows if r["status"] == "USED")
unused = sum(1 for r in rows if r["status"] == "NOT USED")
elastic = sum(1 for r in rows if r["status"] == "ELASTIC")
unknown = sum(1 for r in rows if r["status"] == "UNKNOWN")
if unknown == len(rows):
_dump_entitlement_diagnostics(entitlements, cfg)
cc_results = []
if cfg.get("cloud_connectors"):
info("Checking configured Cloud Connector(s) for this subaccount...")
cc_results = check_cloud_connectors(cfg, subaccount["id"])
for r in cc_results:
if r["error"]:
fail(f"{r['host']}: {r['error']}")
elif r["found"]:
ok(f"{r['host']}: connected - Location ID: {r['location_id']} (tunnel: {r['tunnel_state']})")
else:
info(f"{r['host']}: this subaccount is not connected to this Cloud Connector.")
output_dir = Path(cfg["output_dir"])
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = "".join(c if c.isalnum() else "_" for c in subaccount["name"])
html_path = output_dir / f"entitlements_{safe_name}_{timestamp}.html"
csv_path = output_dir / f"entitlements_{safe_name}_{timestamp}.csv"
render_html_report(subaccount, rows, cc_results, html_path)
render_csv_report(rows, csv_path)
ok(f"Report saved: {html_path}")
ok(f"Raw data saved: {csv_path}")
print(f"\nSummary: {len(rows)} entitlements | {used} in use | {unused} not in use | "
f"{elastic} elastic (always available)")
open_choice = input("\nOpen the HTML report now? [y/N]: ").strip().lower()
if open_choice == "y":
_open_in_browser(html_path)
pause()
def _open_in_browser(path: Path):
try:
if sys.platform == "darwin":
subprocess.run(["open", str(path)])
elif os.name == "nt":
os.startfile(str(path)) # type: ignore[attr-defined]
else:
subprocess.run(["xdg-open", str(path)])
except Exception as e:
info(f"Could not auto-open the report ({e}). Open it manually: {path}")
# --------------------------------------------------------------------------
# 5. Placeholder for future options
# --------------------------------------------------------------------------
def option_list_subscriptions(cfg, subaccount):
info(f"Fetching subscriptions for subaccount '{subaccount['name']}'...")
data = btp_json(cfg, ["list", "accounts/subscription", "--subaccount", subaccount["id"]])
if data is None:
pause()
return
subs = []
if isinstance(data, list):
subs = data
elif isinstance(data, dict):
for key in ("subscriptions", "values", "items"):
value = data.get(key)
if isinstance(value, list):
subs = value
break
else:
for key, value in data.items():
if isinstance(value, list) and value and isinstance(value[0], dict):
info(f"Note: parsed subscriptions from unexpected JSON key '{key}'.")
subs = value
break
else:
info(f"Top-level keys returned: {list(data.keys())}")
print()
for s in subs:
name = _first(s, "appName", "name", default="?")
plan = _first(s, "appPlanName", "planName", default="?")
state = _first(s, "state", default="?")
print(f" - {name:<30} plan={plan:<20} state={state}")
if not subs:
info("No subscriptions found.")
pause()
def option_check_cloud_connectors(cfg, subaccount):
connectors = cfg.get("cloud_connectors") or []
if not connectors:
info("No Cloud Connectors configured. Use [c] Edit configuration to add one.")
pause()
return
info(f"Checking {len(connectors)} configured Cloud Connector(s) for subaccount '{subaccount['name']}'...")
results = check_cloud_connectors(cfg, subaccount["id"])
print()
for r in results:
if r["error"]:
fail(f"{r['host']}: {r['error']}")
elif r["found"]:
ok(f"{r['host']}: connected - Location ID: {r['location_id']} "
f"(tunnel: {r['tunnel_state']}, since {r['connected_since'] or 'unknown'})")
else:
info(f"{r['host']}: this subaccount is not connected to this Cloud Connector.")
pause()
def option_edit_configuration(cfg, subaccount=None):
"""Reachable from the subaccount menu too, in case proxy/paths need a
tweak mid-session. Returns the possibly-updated config; caller should
reassign its local cfg reference."""
new_cfg = run_bootstrap_wizard(existing=cfg)
cfg.clear()
cfg.update(new_cfg)
# --------------------------------------------------------------------------
# 6. Menus
# --------------------------------------------------------------------------
MENU_OPTIONS = [
("1", "Compare entitlements usage (assigned vs. actually used)", option_compare_entitlements),
("2", "List subscriptions", option_list_subscriptions),
("3", "Check connected Cloud Connectors (Location ID)", option_check_cloud_connectors),
# Add more options here as (key, label, handler_function) tuples.
# Handler signature: handler(cfg, subaccount)
]
def subaccount_menu(cfg, subaccount):
while True:
clear_screen()
print(f"Subaccount: {subaccount['name']} ({subaccount['id']})")
hr()
for key, label, _ in MENU_OPTIONS:
print(f" [{key}] {label}")
print(" [c] Edit configuration (proxy / URLs / btp path)")
print(" [b] Back to subaccount list")
print(" [q] Quit")
hr()
choice = input("Choose an option: ").strip().lower()
if choice == "q":
sys.exit(0)
if choice == "b":
return
if choice == "c":
option_edit_configuration(cfg)
continue
for key, _, handler in MENU_OPTIONS:
if choice == key:
handler(cfg, subaccount)
break
else:
fail("Invalid option.")
pause()
def main():
clear_screen()
print("=" * 70)
print(" SAP BTP - Entitlements & Usage Tool")
print("=" * 70)
cfg = get_config()
set_proxy_env(cfg)
check_btp_cli(cfg)
if not btp_login(cfg):
sys.exit(1)
while True:
subaccount = choose_subaccount(cfg)
if subaccount is None:
print("\nGoodbye.")
break
subaccount_menu(cfg, subaccount)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nInterrupted. Goodbye.")
sys.exit(0)