553 lines
20 KiB
Python
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()
|