#!/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 python maintenance-api-python.py start \ --api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \ --base-url https://superset-tools.example.com \ --tables public.messages,public.users \ --duration-hours 4 \ --message "Scheduled ETL refresh" # 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 """ 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, duration_hours: int = 4, message: str | None = None, timeout: int = 30) -> 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") duration_hours: How long the maintenance window lasts message: Optional custom message for the banner timeout: Request timeout in seconds Returns: API response dict with task_id and maintenance_id Raises: requests.HTTPError: On 4xx/5xx response with error details """ 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, "environment_id": environment_id, } 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}") response = requests.post( f"{base_url}/api/maintenance/start", json=payload, headers={API_KEY_HEADER: api_key}, timeout=timeout, ) if response.status_code == 202: result = response.json() print(f"[maintenance] ✅ Event created (id={result['maintenance_id']})") print(f"[maintenance] Task: {result.get('task_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'") 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) -> 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 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}, timeout=timeout, ) if response.status_code == 202: result = response.json() print(f"[maintenance] ✅ Event end scheduled (task={result.get('task_id', 'N/A')})") return result if response.status_code == 404: data = response.json() if data.get("status") == "already_completed": print("[maintenance] ℹ️ Event was already completed (idempotent)") return data 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) -> 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. 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] ⚠️ 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}, timeout=timeout, ) if response.status_code == 202: result = response.json() print(f"[maintenance] ✅ 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 # 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 """, ) parser.add_argument( "--base-url", default="http://localhost:8000", help="superset-tools base URL (default: %(default)s)", ) parser.add_argument( "--api-key", required=True, help="API key with required permissions", ) subparsers = parser.add_subparsers(dest="command", required=True) # start start_parser = subparsers.add_parser("start", 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=True, dest="environment_id", help="Superset environment ID (e.g. 'ss-dev', 'ss-prod')", ) 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", ) # end end_parser = subparsers.add_parser("end", 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", 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, ) elif args.command == "end": end_maintenance( base_url=args.base_url, api_key=args.api_key, event_id=args.event_id, ) elif args.command == "end-all": end_all_maintenance( base_url=args.base_url, api_key=args.api_key, environment_id=args.environment_id, ) except requests.HTTPError: sys.exit(1) if __name__ == "__main__": main()