Files
ss-tools/examples/maintenance/maintenance-api-python.py

553 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Example: External tool triggers maintenance via superset-tools API.
This script demonstrates how an ETL pipeline (Airflow DAG, cron job, CI/CD)
can start and end maintenance banners on Superset dashboards using the
superset-tools API Key authentication.
Requirements: Python 3.10+, requests (pip install requests)
Usage:
# Start maintenance in an explicit environment
python maintenance-api-python.py start \
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
--base-url https://superset-tools.example.com \
--tables public.messages,public.users \
--environment ss-dev \
--duration-hours 4 \
--message "Scheduled ETL refresh"
# Fan-out: omit --environment to start in ALL PROD environments (batch response)
python maintenance-api-python.py start \
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
--base-url https://superset-tools.example.com \
--tables public.messages \
--duration-hours 4
# Same, but auto-end the maintenance at end_time (API param auto_end=true)
python maintenance-api-python.py start \
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
--base-url https://superset-tools.example.com \
--tables public.messages \
--environment ss-prod \
--duration-hours 4 \
--auto-end
# End maintenance by event ID
python maintenance-api-python.py end \
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
--base-url https://superset-tools.example.com \
--event-id m-abc123
# End ALL maintenance (use with caution!)
python maintenance-api-python.py end-all \
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
--base-url https://superset-tools.example.com \
--environment ss-dev
=============================== API SPECIFICATION ============================
API
--------------------------------
URL: {BASE_URL}/api/maintenance
: X-API-Key: <API_KEY> ( JWT-)
API- (permissions), :
maintenance:start (POST /start)
maintenance:end (POST /{id}/end)
maintenance:end_all (POST /end-all)
( ),
{ "task_id": "...", "maintenance_id": "...", "status": "pending" }.
HTTP 202 task_id
(TaskManager).
--- POST /api/maintenance/start ---
HTTP 202 Accepted | : maintenance:start
(JSON):
tables [string] . (1..100).
start_time datetime . (ISO 8601).
end_time datetime . start_time.
auto_end bool false. true end_time,
end_time.
message string . (. 500 .).
environment_id string . (ss-dev ..).
()
PROD- (fan-out), 202
batch- events[].
202:
- (environment_id ):
{ "task_id": "...", "maintenance_id": "...", "status": "pending" }
- batch- (environment_id , fan-out PROD):
{ "status": "pending", "events": [
{ "environment_id": "ss-prod-a", "task_id": "...",
"maintenance_id": "...", "status": "pending" },
... ] }
:
400 (end_time <= start_time; start_time
, 1 ; > 100 );
401 / ;
403 ;
404 environment_id ( );
422 environment_id , PROD- fan-out
;
409 (
tables/start_time/end_time ):
{ "maintenance_id": "...", "status": "already_active" }.
environment_id; batch- 409
per-item status
"already_active" 202 .
--- POST /api/maintenance/{maintenance_id}/end ---
HTTP 202 Accepted | : maintenance:end | : .
202: { "task_id": "...", "status": "pending" }
:
404 ;
401/403 /.
: end 202,
status == "already_completed" ( ).
--- POST /api/maintenance/end-all ---
HTTP 202 Accepted | : maintenance:end_all
: .
(JSON, ): { "environment_id": "..." }
API.
202: { "task_id": "...", "status": "pending" }
--- (read-only) , READ ---
GET /api/maintenance/events active completed
GET /api/maintenance/events/{id}/dashboards ({id,title});
,
GET /api/maintenance/dashboard-banners
POST /api/maintenance/preview-dashboards
GET /api/maintenance/settings
PUT /api/maintenance/settings ( admin)
=========================== () ==============================
:
Superset (ETL, cron, CI/CD).
:
1. : pip install requests
2. API- superset-tools
(maintenance:start / maintenance:end / maintenance:end_all).
3. --base-url ( ) --api-key.
:
public.messages 4 ss-dev:
python maintenance-api-python.py start \
--api-key ssk_... --base-url https://superset-tools.example.com \
--tables public.messages --environment ss-dev --duration-hours 4
Fan-out PROD- ( --environment; batch- events[]):
python maintenance-api-python.py start \
--api-key ssk_... --tables public.messages --duration-hours 4
, :
python maintenance-api-python.py start \
--api-key ssk_... --tables public.messages,public.users \
--environment ss-prod --duration-hours 4 \
--message " ETL" --auto-end
:
python maintenance-api-python.py end \
--api-key ssk_... --event-id m-abc123
ss-dev:
python maintenance-api-python.py end-all \
--api-key ssk_... --environment ss-dev
--auto-end:
start_time end_time ( +).
--auto-end end_time `end`.
--auto-end end_time.
: 0 ; 1 (HTTPError ).
:
- API CI/cron,
; /.
- `end-all` .
================================================================================
"""
import argparse
import sys
from datetime import datetime, timezone, timedelta
try:
import requests
except ImportError:
print("Error: 'requests' library is required. Install with: pip install requests")
sys.exit(1)
API_KEY_HEADER = "X-API-Key"
def start_maintenance(base_url: str, api_key: str, tables: list[str],
environment_id: str | None, duration_hours: int = 4,
message: str | None = None, auto_end: bool = False,
timeout: int = 30, insecure: bool = False) -> dict:
"""
Start a maintenance event.
Args:
base_url: superset-tools base URL (e.g. https://superset-tools.example.com)
api_key: API key with 'maintenance:start' permission
tables: List of table names to flag (e.g. ["public.messages"])
environment_id: Superset environment (e.g. "ss-dev", "ss-prod").
OPTIONAL: when None, the server fans the start out to ALL configured
PROD environments and returns a batch response {status, events[]}.
duration_hours: How long the maintenance window lasts
message: Optional custom message for the banner
auto_end: When True, sends auto_end=true so the scheduler removes the banner
automatically at end_time. When False (default), end_time is informational
only and maintenance must be ended manually via `end`.
insecure: Disable TLS certificate verification for self-signed certificates.
timeout: Request timeout in seconds
Returns:
API response dict:
- single shape (environment_id given): {task_id, maintenance_id, status}
- batch shape (environment_id omitted): {status:"pending", events:[
{environment_id, task_id?, maintenance_id,
status:"pending"|"already_active"}, ...]}
Raises:
requests.HTTPError: On 4xx/5xx response with error details
(including 422 when no PROD environment is configured for fan-out)
Note:
Idempotent retry: identical (tables, start_time, end_time) against an
active event returns 409 with status='already_active' and the existing
maintenance_id treated as success here. Idempotency is scoped per
environment_id; in batch mode a duplicate yields per-item
status='already_active' inside the 202 response (no 409).
"""
now = datetime.now(timezone.utc)
start_time = now.isoformat()
end_time = (now + timedelta(hours=duration_hours)).isoformat()
payload = {
"tables": tables,
"start_time": start_time,
"end_time": end_time,
}
if environment_id is not None:
payload["environment_id"] = environment_id
if auto_end:
payload["auto_end"] = True
if message:
payload["message"] = message
print(f"[maintenance] Starting maintenance for tables: {tables}")
print(f"[maintenance] Window: {start_time} {end_time}")
print(f"[maintenance] Environment: {environment_id if environment_id is not None else 'ALL PROD (fan-out)'}")
if auto_end:
print("[maintenance] Auto-end: ENABLED (banner will be removed at end_time)")
response = requests.post(
f"{base_url}/api/maintenance/start",
json=payload,
headers={API_KEY_HEADER: api_key},
verify=not insecure,
timeout=timeout,
)
if response.status_code == 202:
result = response.json()
if "events" in result:
# Batch shape: fan-out start across all PROD environments.
print(f"[maintenance] [OK] Batch start accepted "
f"({len(result.get('events', []))} environment(s)):")
for item in result.get("events", []):
env = item.get("environment_id", "N/A")
mid = item.get("maintenance_id", "N/A")
item_status = item.get("status", "N/A")
if item_status == "already_active":
print(f"[maintenance] [INFO] {env}: already active "
f"(idempotent, id={mid})")
else:
print(f"[maintenance] {env}: event id={mid} "
f"(task={item.get('task_id', 'N/A')}, status={item_status})")
return result
print(f"[maintenance] [OK] Event created (id={result['maintenance_id']})")
print(f"[maintenance] Task: {result.get('task_id', 'N/A')}")
return result
if response.status_code == 409:
result = response.json()
if result.get("status") == "already_active":
print(f"[maintenance] Event already active (idempotent, id={result.get('maintenance_id', 'N/A')})")
return result
if response.status_code == 400:
error = _format_error(response)
print(f"[maintenance] Validation error: {error}")
elif response.status_code == 401:
print("[maintenance] Authentication failed: invalid or revoked API key")
elif response.status_code == 403:
print("[maintenance] Permission denied: API key lacks 'maintenance:start'")
elif response.status_code == 404:
print(f"[maintenance] Unknown environment_id: {environment_id}")
elif response.status_code == 422:
print("[maintenance] no PROD environments configured for fan-out start")
else:
print(f"[maintenance] Unexpected error ({response.status_code}): {response.text[:500]}")
response.raise_for_status()
return {} # unreachable
def end_maintenance(base_url: str, api_key: str, event_id: str,
timeout: int = 30, insecure: bool = False) -> dict:
"""
End a specific maintenance event and remove its banners.
Args:
base_url: superset-tools base URL
api_key: API key with 'maintenance:end' permission
event_id: The event ID returned by start_maintenance
insecure: Disable TLS certificate verification for self-signed certificates.
timeout: Request timeout in seconds
Returns:
API response dict
Raises:
requests.HTTPError: On error
"""
print(f"[maintenance] Ending maintenance event: {event_id}")
response = requests.post(
f"{base_url}/api/maintenance/{event_id}/end",
headers={API_KEY_HEADER: api_key},
verify=not insecure,
timeout=timeout,
)
if response.status_code == 202:
result = response.json()
if result.get("status") == "already_completed":
print("[maintenance] [INFO] Event was already completed (idempotent)")
return result
print(f"[maintenance] [OK] Event end scheduled (task={result.get('task_id', 'N/A')})")
return result
if response.status_code == 404:
print(f"[maintenance] Event not found: {event_id}")
elif response.status_code == 401:
print("[maintenance] Authentication failed: invalid or revoked API key")
elif response.status_code == 403:
print("[maintenance] Permission denied: API key lacks 'maintenance:end'")
else:
print(f"[maintenance] Unexpected error ({response.status_code}): {response.text[:500]}")
response.raise_for_status()
return {}
def end_all_maintenance(base_url: str, api_key: str,
environment_id: str | None = None,
timeout: int = 30, insecure: bool = False) -> dict:
"""
End ALL active maintenance events in the given environment.
USE WITH CAUTION this removes banners from all dashboards.
Args:
base_url: superset-tools base URL
api_key: API key with 'maintenance:end_all' permission
environment_id: If set, only end events in this environment.
If None, uses the API key's default scope.
insecure: Disable TLS certificate verification for self-signed certificates.
timeout: Request timeout in seconds
Returns:
API response dict
Raises:
requests.HTTPError: On error
"""
print(f"[maintenance] Ending ALL maintenance events "
f"(env={environment_id or 'key-scope'})")
print("[maintenance] [WARN] This will remove banners from ALL affected dashboards!")
payload = {}
if environment_id:
payload["environment_id"] = environment_id
response = requests.post(
f"{base_url}/api/maintenance/end-all",
json=payload,
headers={API_KEY_HEADER: api_key},
verify=not insecure,
timeout=timeout,
)
if response.status_code == 202:
result = response.json()
print(f"[maintenance] [OK] All events end scheduled (task={result.get('task_id', 'N/A')})")
return result
if response.status_code == 401:
print("[maintenance] Authentication failed: invalid or revoked API key")
elif response.status_code == 403:
print("[maintenance] Permission denied: API key lacks 'maintenance:end_all'")
elif response.status_code == 400:
print(f"[maintenance] {_format_error(response)}")
else:
print(f"[maintenance] Unexpected error ({response.status_code}): {response.text[:500]}")
response.raise_for_status()
return {}
def _format_error(response: requests.Response) -> str:
"""Extract human-readable error from API response."""
try:
data = response.json()
if "detail" in data:
detail = data["detail"]
if isinstance(detail, list):
return "; ".join(d.get("msg", str(d)) for d in detail)
return str(detail)
return response.text[:200]
except Exception:
return response.text[:200]
def main():
parser = argparse.ArgumentParser(
description="superset-tools Maintenance Banner CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Start 4-hour maintenance on public.messages
%(prog)s start --api-key ssk_... --tables public.messages \\\\
--environment ss-dev --duration-hours 4
# Fan-out: omit --environment to start in ALL PROD environments (batch response)
%(prog)s start --api-key ssk_... --tables public.messages --duration-hours 4
# End maintenance by event ID
%(prog)s end --api-key ssk_... --event-id m-abc123
# Emergency: end ALL maintenance in ss-dev
%(prog)s end-all --api-key ssk_... --environment ss-dev
""",
)
# Common options are defined on each subparser (via parents) so they can be
# placed AFTER the subcommand matching the usage examples above. argparse
# does not reliably accept parent-parser options after the subcommand.
common_opts = argparse.ArgumentParser(add_help=False)
common_opts.add_argument(
"--base-url",
default="http://localhost:8000",
help="superset-tools base URL (default: %(default)s)",
)
common_opts.add_argument(
"--api-key",
required=True,
help="API key with required permissions",
)
common_opts.add_argument(
"--insecure",
action="store_true",
help="Disable TLS certificate verification (use for self-signed certificates)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# start
start_parser = subparsers.add_parser("start", parents=[common_opts], help="Start maintenance")
start_parser.add_argument(
"--tables",
required=True,
help="Comma-separated table names (e.g. 'public.messages,public.users')",
)
start_parser.add_argument(
"--environment",
required=False,
default=None,
dest="environment_id",
help="Superset environment ID (e.g. 'ss-dev', 'ss-prod'). OPTIONAL: "
"omit to start maintenance in ALL PROD environments (fan-out, batch response)",
)
start_parser.add_argument(
"--duration-hours",
type=int,
default=4,
help="Maintenance window in hours (default: %(default)s)",
)
start_parser.add_argument(
"--message",
default=None,
help="Optional message for the banner",
)
start_parser.add_argument(
"--auto-end",
action="store_true",
help="Also send auto_end=true so the scheduler removes the banner automatically "
"at end_time. Without it, end_time is informational only.",
)
# end
end_parser = subparsers.add_parser("end", parents=[common_opts], help="End a specific maintenance event")
end_parser.add_argument(
"--event-id",
required=True,
help="Maintenance event ID (from start response)",
)
# end-all
end_all_parser = subparsers.add_parser(
"end-all", parents=[common_opts], help="End ALL maintenance events (use with caution)"
)
end_all_parser.add_argument(
"--environment",
dest="environment_id",
default=None,
help="Limit to specific environment (optional)",
)
args = parser.parse_args()
try:
if args.command == "start":
tables = [t.strip() for t in args.tables.split(",")]
start_maintenance(
base_url=args.base_url,
api_key=args.api_key,
tables=tables,
environment_id=args.environment_id,
duration_hours=args.duration_hours,
message=args.message,
auto_end=args.auto_end,
insecure=args.insecure,
)
elif args.command == "end":
end_maintenance(
base_url=args.base_url,
api_key=args.api_key,
event_id=args.event_id,
insecure=args.insecure,
)
elif args.command == "end-all":
end_all_maintenance(
base_url=args.base_url,
api_key=args.api_key,
environment_id=args.environment_id,
insecure=args.insecure,
)
except requests.HTTPError:
# Specific error message was already printed by the command function.
sys.exit(1)
except requests.RequestException as exc:
# Network-level failure (connection refused, timeout, DNS, ...).
print(f"[maintenance] Request failed: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()