- bash: propagate api_call failures (exit 1 on 400/401/403/404/network), write diagnostics to stderr, escape message for JSON safety, help without API key - python: argparse options after subcommand (parents), single error message per failure, network errors without traceback, idempotent already_completed - move scripts to examples/maintenance/ with README instructions - backend: correct stale envelope-shape comment in maintenance schemas
473 lines
19 KiB
Python
473 lines
19 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
|
||
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"
|
||
|
||
# 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 \
|
||
--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 и т.п.).
|
||
Ответ 202: { "task_id": "...", "maintenance_id": "...", "status": "pending" }
|
||
Ошибки:
|
||
400 — невалидные данные (end_time <= start_time; start_time слишком в
|
||
прошлом, допуск 1 час; > 100 таблиц);
|
||
401 — неверный/отозванный ключ;
|
||
403 — недостаточно прав;
|
||
404 — неизвестное environment_id;
|
||
409 — идемпотентность (такое же tables/start_time/end_time уже активно):
|
||
{ "maintenance_id": "...", "status": "already_active" }.
|
||
|
||
--- 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/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
|
||
Несколько таблиц, с сообщением и автоснятием баннера:
|
||
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, duration_hours: int = 4,
|
||
message: str | None = None, auto_end: bool = False,
|
||
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
|
||
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`.
|
||
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
|
||
|
||
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.
|
||
"""
|
||
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 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 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},
|
||
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 == 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'")
|
||
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()
|
||
if result.get("status") == "already_completed":
|
||
print("[maintenance] ℹ️ Event was already completed (idempotent)")
|
||
return result
|
||
print(f"[maintenance] ✅ 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) -> 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
|
||
""",
|
||
)
|
||
|
||
# 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",
|
||
)
|
||
|
||
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=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",
|
||
)
|
||
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,
|
||
)
|
||
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:
|
||
# 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()
|