fix(examples): maintenance API scripts — error handling, JSON safety, docs
- 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
This commit is contained in:
148
examples/maintenance/README.md
Normal file
148
examples/maintenance/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Maintenance API — примеры интеграции
|
||||
|
||||
Примеры внешних вызовов API обслуживания (баннеры «ведутся технические работы»
|
||||
на дашбордах Superset) из скриптов: CI/CD, cron, Airflow DAG, ad-hoc отладка.
|
||||
|
||||
## Содержимое
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `maintenance-api-bash.sh` | Пример на bash (curl) — для shell-окружений без Python |
|
||||
| `maintenance-api-python.py` | Пример на Python (requests) — для ETL-пайплайнов |
|
||||
|
||||
## Возможности
|
||||
|
||||
- **start** — создать событие обслуживания для таблиц (1..100) с окном
|
||||
начала/окончания, сообщением баннера и опциональным авто-завершением
|
||||
(`--auto-end` / `auto_end=true`);
|
||||
- **end** — завершить конкретное событие (снять баннеры);
|
||||
- **end-all** — аварийно завершить ВСЕ активные события в окружении.
|
||||
|
||||
## API — кратко
|
||||
|
||||
- Базовый URL: `{BASE_URL}/api/maintenance`
|
||||
- Аутентификация: заголовок `X-API-Key: <API_KEY>` (или JWT)
|
||||
- Разрешения ключа: `maintenance:start`, `maintenance:end`, `maintenance:end_all`
|
||||
- Все мутационные эндпоинты возвращают **HTTP 202** и `task_id` — операция
|
||||
выполняется асинхронно через TaskManager.
|
||||
- Ответы — сырые тела схем (без обёртки), например:
|
||||
`{ "task_id": "...", "maintenance_id": "...", "status": "pending" }`.
|
||||
|
||||
### Эндпоинты
|
||||
|
||||
| Метод | Путь | Разрешение | Описание |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/maintenance/start` | `maintenance:start` | Создать событие (202) |
|
||||
| POST | `/api/maintenance/{id}/end` | `maintenance:end` | Завершить событие (202) |
|
||||
| POST | `/api/maintenance/end-all` | `maintenance:end_all` | Завершить все (202) |
|
||||
| GET | `/api/maintenance/events` | `maintenance` READ | Активные/завершённые события |
|
||||
| GET | `/api/maintenance/dashboard-banners` | `maintenance` READ | Баннеры по дашбордам |
|
||||
| POST | `/api/maintenance/preview-dashboards` | `maintenance` READ | Какие дашборды затронуты |
|
||||
| GET/PUT | `/api/maintenance/settings` | READ / admin | Настройки обслуживания |
|
||||
|
||||
### Параметры `start`
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": ["public.messages", "public.users"],
|
||||
"start_time": "2026-08-10T08:00:00Z",
|
||||
"end_time": "2026-08-10T12:00:00Z",
|
||||
"environment_id": "ss-dev",
|
||||
"auto_end": false,
|
||||
"message": "Плановое обновление данных"
|
||||
}
|
||||
```
|
||||
|
||||
- `tables` — обязателен (1..100); `start_time`, `environment_id` — обязательны.
|
||||
- `auto_end` — по умолчанию `false`; если `true` и задан `end_time`,
|
||||
планировщик сам завершит обслуживание ровно в `end_time`. **Без `auto_end`
|
||||
событие останется активным до ручного завершения командой `end`.**
|
||||
- Ошибки: `400` (невалидные данные, `end_time <= start_time`, >100 таблиц),
|
||||
`401`, `403`, `404` (неизвестное окружение), `409` — идемпотентность
|
||||
(`status: "already_active"` — такое же окно уже активно).
|
||||
- `end` идемпотентен: повторное завершение уже завершённого события даёт
|
||||
`202` с `status: "already_completed"` (считается успехом).
|
||||
|
||||
## Использование
|
||||
|
||||
### Требования
|
||||
|
||||
- **bash**: `curl`, желательно `jq` (форматирование ответов; без него вывод
|
||||
остаётся сырым JSON).
|
||||
- **python**: Python 3.10+, `requests` (`pip install requests`).
|
||||
|
||||
### Подготовка
|
||||
|
||||
1. Создайте API-ключ в superset-tools с разрешениями
|
||||
`maintenance:start` / `maintenance:end` / `maintenance:end_all`.
|
||||
2. Задайте переменные окружения (bash) или флаги `--base-url` / `--api-key`
|
||||
(python).
|
||||
|
||||
### Bash
|
||||
|
||||
```bash
|
||||
export SS_TOOLS_URL=http://localhost:8000
|
||||
export SS_TOOLS_API_KEY=ssk_ваш_ключ
|
||||
|
||||
# Обслуживание на 4 часа (без авто-завершения)
|
||||
./maintenance-api-bash.sh start public.messages 4 ss-dev
|
||||
|
||||
# Несколько таблиц, сообщение, автоснятие баннера через 4 часа
|
||||
./maintenance-api-bash.sh start public.messages,public.users 4 ss-prod \
|
||||
"Плановый ETL" --auto-end
|
||||
|
||||
# Завершить конкретное событие
|
||||
./maintenance-api-bash.sh end m-abc123
|
||||
|
||||
# Аварийно снять ВСЕ баннеры в окружении ss-dev
|
||||
./maintenance-api-bash.sh end-all ss-dev
|
||||
```
|
||||
|
||||
Выходные коды: `0` — успех; `1` — ошибка (данные, права, ключ, недоступность
|
||||
сервера). `end-all` в интерактивном режиме запрашивает подтверждение; в
|
||||
неинтерактивных окружениях (cron/CI) подтверждение пропускается.
|
||||
|
||||
### Python
|
||||
|
||||
```bash
|
||||
# Старт (аргументы можно указывать и после подкоманды)
|
||||
python maintenance-api-python.py start \
|
||||
--api-key ssk_ваш_ключ \
|
||||
--base-url https://superset-tools.example.com \
|
||||
--tables public.messages,public.users \
|
||||
--environment ss-dev --duration-hours 4 \
|
||||
--message "Плановый ETL" --auto-end
|
||||
|
||||
# Завершить конкретное событие
|
||||
python maintenance-api-python.py end \
|
||||
--api-key ssk_ваш_ключ \
|
||||
--base-url https://superset-tools.example.com \
|
||||
--event-id m-abc123
|
||||
|
||||
# Аварийно завершить всё в окружении
|
||||
python maintenance-api-python.py end-all \
|
||||
--api-key ssk_ваш_ключ \
|
||||
--base-url https://superset-tools.example.com \
|
||||
--environment ss-dev
|
||||
```
|
||||
|
||||
Функции `start_maintenance` / `end_maintenance` / `end_all_maintenance` можно
|
||||
импортировать напрямую в свой ETL-пайплайн.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **Не передавайте API-ключ аргументами командной строки** (попадает в историю
|
||||
shell/ps) — используйте переменные окружения или защищённые секреты
|
||||
(CI/CD secrets, vault).
|
||||
- Ключ с ограниченным окружением может работать только с этим окружением —
|
||||
выберите правильный `environment_id`.
|
||||
- `end-all` снимает баннеры со всех дашбордов — применяйте только в аварийных
|
||||
сценариях.
|
||||
|
||||
## Примеры автоматизации
|
||||
|
||||
- **cron** (снятие баннера ровно в конце окна через `auto_end` не нужно —
|
||||
планировщик сам завершит; `end` пригодится для досрочного завершения).
|
||||
- **CI/CD pipeline**: после деплоя ETL — `start`; по завершении джобы — `end`.
|
||||
- **Airflow DAG**: Python-функции из `maintenance-api-python.py` в
|
||||
`PythonOperator` либо `BashOperator` со скриптом.
|
||||
@@ -16,7 +16,9 @@
|
||||
# maintenance:end — завершение конкретного события (POST /{id}/end)
|
||||
# maintenance:end_all — массовое завершение всех событий (POST /end-all)
|
||||
#
|
||||
# Все ответы приходят в конверте-обёртке {status, data, error, meta}.
|
||||
# ----------------------------------------------------------------------------
|
||||
# Все ответы приходят как сырое тело схемы (без обёртки), например:
|
||||
# { "task_id": "…", "maintenance_id": "…", "status": "pending" }
|
||||
# Все мутационные (изменяющие) эндпоинты возвращают HTTP 202 и task_id —
|
||||
# операция выполняется асинхронно через планировщик (TaskManager).
|
||||
#
|
||||
@@ -54,9 +56,10 @@
|
||||
# Тело: отсутствует.
|
||||
# Ответ 202: { "task_id": "…", "status": "pending" }
|
||||
# Ошибки:
|
||||
# 404 — событие не найдено; если status == "already_completed" — событие
|
||||
# уже завершено (идемпотентно, считается успехом);
|
||||
# 404 — событие не найдено;
|
||||
# 401/403 — аутентификация/права.
|
||||
# Идемпотентность: повторный end уже завершённого события — тоже 202,
|
||||
# но с status == "already_completed" (считается успехом).
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# 3) POST /api/maintenance/end-all — завершить ВСЕ активные события
|
||||
@@ -139,7 +142,10 @@ fi
|
||||
ok() { echo -e "${GREEN}✅${NC} $*"; }
|
||||
info() { echo -e "${CYAN}ℹ️ ${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠️ ${NC} $*"; }
|
||||
fail() { echo -e "${RED}❌${NC} $*"; exit 1; }
|
||||
# NOTE: fail() writes to stderr and exits the current shell. Because api_call is
|
||||
# invoked inside `$(...)` (a subshell), the exit only propagates as a non-zero
|
||||
# status — callers must `|| return 1` to stop the command chain.
|
||||
fail() { echo -e "${RED}❌${NC} $*" >&2; exit 1; }
|
||||
|
||||
# ── Auth check ─────────────────────────────────────────────────────────────
|
||||
check_auth() {
|
||||
@@ -148,6 +154,13 @@ check_auth() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── JSON escaping helper ───────────────────────────────────────────────────
|
||||
# Escape backslashes and double quotes, strip control chars, so free-text
|
||||
# fields (e.g. message) never break the JSON payload.
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr -d '\000-\037'
|
||||
}
|
||||
|
||||
# ── API helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
# Call the API with X-API-Key auth and handle common errors
|
||||
@@ -181,12 +194,12 @@ api_call() {
|
||||
|
||||
case "$http_code" in
|
||||
202)
|
||||
echo "$body" # success
|
||||
echo "$body" # success — only the raw body goes to stdout (captured by callers)
|
||||
return 0
|
||||
;;
|
||||
400)
|
||||
warn "Validation error:"
|
||||
echo "$body" | jq . 2>/dev/null || echo "$body"
|
||||
warn "Validation error:" >&2
|
||||
echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2
|
||||
return 1
|
||||
;;
|
||||
401)
|
||||
@@ -196,26 +209,20 @@ api_call() {
|
||||
fail "Permission denied: API key lacks required permission"
|
||||
;;
|
||||
404)
|
||||
local status
|
||||
status=$(echo "$body" | jq -r '.status // "not_found"' 2>/dev/null)
|
||||
if [[ "$status" == "already_completed" ]]; then
|
||||
info "Event was already completed (idempotent)"
|
||||
return 0
|
||||
fi
|
||||
warn "Not found (404):"
|
||||
echo "$body" | jq . 2>/dev/null || echo "$body"
|
||||
warn "Not found (404):" >&2
|
||||
echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2
|
||||
return 1
|
||||
;;
|
||||
409)
|
||||
local status
|
||||
status=$(echo "$body" | jq -r '.status // ""' 2>/dev/null)
|
||||
if [[ "$status" == "already_active" ]]; then
|
||||
info "Event already active (idempotent)"
|
||||
echo "$body" | jq . 2>/dev/null || echo "$body"
|
||||
info "Event already active (idempotent)" >&2
|
||||
echo "$body" # raw body to stdout for the caller (maintenance_id)
|
||||
return 0
|
||||
fi
|
||||
warn "Conflict (409):"
|
||||
echo "$body" | jq . 2>/dev/null || echo "$body"
|
||||
warn "Conflict (409):" >&2
|
||||
echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
@@ -253,7 +260,7 @@ EOF
|
||||
info "Auto-end enabled: banner will be removed at ${end_time}"
|
||||
fi
|
||||
if [[ -n "$message" ]]; then
|
||||
payload="$payload"$',\n "message": "'"$message"'"'
|
||||
payload="$payload"$',\n "message": "'"$(json_escape "$message")"'"'
|
||||
fi
|
||||
payload="$payload"$'\n}'
|
||||
|
||||
@@ -261,15 +268,14 @@ EOF
|
||||
info "Window: ${start_time} → ${end_time}"
|
||||
|
||||
local response
|
||||
if response=$(api_call POST "/api/maintenance/start" "$payload"); then
|
||||
local event_id
|
||||
event_id=$(echo "$response" | jq -r '.maintenance_id' 2>/dev/null || echo "unknown")
|
||||
local task_id
|
||||
task_id=$(echo "$response" | jq -r '.task_id' 2>/dev/null || echo "unknown")
|
||||
ok "Maintenance event created: ${BOLD}${event_id}${NC}"
|
||||
info "Task: ${task_id}"
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
fi
|
||||
response=$(api_call POST "/api/maintenance/start" "$payload") || return 1
|
||||
local event_id
|
||||
event_id=$(echo "$response" | jq -r '.maintenance_id' 2>/dev/null || echo "unknown")
|
||||
local task_id
|
||||
task_id=$(echo "$response" | jq -r '.task_id' 2>/dev/null || echo "unknown")
|
||||
ok "Maintenance event created: ${BOLD}${event_id}${NC}"
|
||||
info "Task: ${task_id}"
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
}
|
||||
|
||||
cmd_end() {
|
||||
@@ -278,10 +284,9 @@ cmd_end() {
|
||||
info "Ending maintenance event: ${event_id}"
|
||||
|
||||
local response
|
||||
if response=$(api_call POST "/api/maintenance/${event_id}/end" ""); then
|
||||
ok "Maintenance event end scheduled"
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
fi
|
||||
response=$(api_call POST "/api/maintenance/${event_id}/end" "") || return 1
|
||||
ok "Maintenance event end scheduled"
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
}
|
||||
|
||||
cmd_end_all() {
|
||||
@@ -313,16 +318,13 @@ EOF
|
||||
fi
|
||||
|
||||
local response
|
||||
if response=$(api_call POST "/api/maintenance/end-all" "$payload"); then
|
||||
ok "End-all scheduled"
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
fi
|
||||
response=$(api_call POST "/api/maintenance/end-all" "$payload") || return 1
|
||||
ok "End-all scheduled"
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
check_auth
|
||||
|
||||
# Filter the --auto-end flag out of the positional args
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
@@ -336,6 +338,7 @@ set -- "${ARGS[@]}"
|
||||
|
||||
case "${1:-help}" in
|
||||
start)
|
||||
check_auth
|
||||
if [[ $# -lt 3 ]]; then
|
||||
echo "Usage: $0 start <tables> [duration_hours] <environment> [message] [--auto-end]"
|
||||
echo ""
|
||||
@@ -350,6 +353,7 @@ case "${1:-help}" in
|
||||
cmd_start "$2" "${3:-4}" "${4:-}" "${5:-}"
|
||||
;;
|
||||
end)
|
||||
check_auth
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 end <event-id>"
|
||||
exit 1
|
||||
@@ -357,6 +361,7 @@ case "${1:-help}" in
|
||||
cmd_end "$2"
|
||||
;;
|
||||
end-all)
|
||||
check_auth
|
||||
cmd_end_all "${2:-}"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -48,7 +48,8 @@ Usage:
|
||||
maintenance:end — завершение конкретного события (POST /{id}/end)
|
||||
maintenance:end_all — массовое завершение всех событий (POST /end-all)
|
||||
|
||||
Все ответы приходят в обёртке {status, data, error, meta}.
|
||||
Ответы мутационных эндпоинтов — это сырые тела схем (без обёртки), например
|
||||
{ "task_id": "...", "maintenance_id": "...", "status": "pending" }.
|
||||
Все мутационные эндпоинты возвращают HTTP 202 и task_id — операция
|
||||
выполняется асинхронно планировщиком (TaskManager).
|
||||
|
||||
@@ -76,9 +77,10 @@ Usage:
|
||||
HTTP 202 Accepted | Разрешение: maintenance:end | Тело: отсутствует.
|
||||
Ответ 202: { "task_id": "...", "status": "pending" }
|
||||
Ошибки:
|
||||
404 — событие не найдено; если status == "already_completed" — уже
|
||||
завершено (идемпотентно, успех);
|
||||
404 — событие не найдено;
|
||||
401/403 — аутентификация/права.
|
||||
Идемпотентность: повторный end уже завершённого события — тоже 202,
|
||||
но с status == "already_completed" (считается успехом).
|
||||
|
||||
--- POST /api/maintenance/end-all ---
|
||||
HTTP 202 Accepted | Разрешение: maintenance:end_all
|
||||
@@ -260,14 +262,13 @@ def end_maintenance(base_url: str, api_key: str, event_id: str,
|
||||
|
||||
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:
|
||||
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")
|
||||
@@ -364,12 +365,17 @@ Examples:
|
||||
%(prog)s end-all --api-key ssk_... --environment ss-dev
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
# 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)",
|
||||
)
|
||||
parser.add_argument(
|
||||
common_opts.add_argument(
|
||||
"--api-key",
|
||||
required=True,
|
||||
help="API key with required permissions",
|
||||
@@ -378,7 +384,7 @@ Examples:
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# start
|
||||
start_parser = subparsers.add_parser("start", help="Start maintenance")
|
||||
start_parser = subparsers.add_parser("start", parents=[common_opts], help="Start maintenance")
|
||||
start_parser.add_argument(
|
||||
"--tables",
|
||||
required=True,
|
||||
@@ -409,7 +415,7 @@ Examples:
|
||||
)
|
||||
|
||||
# end
|
||||
end_parser = subparsers.add_parser("end", help="End a specific maintenance event")
|
||||
end_parser = subparsers.add_parser("end", parents=[common_opts], help="End a specific maintenance event")
|
||||
end_parser.add_argument(
|
||||
"--event-id",
|
||||
required=True,
|
||||
@@ -418,7 +424,7 @@ Examples:
|
||||
|
||||
# end-all
|
||||
end_all_parser = subparsers.add_parser(
|
||||
"end-all", help="End ALL maintenance events (use with caution)"
|
||||
"end-all", parents=[common_opts], help="End ALL maintenance events (use with caution)"
|
||||
)
|
||||
end_all_parser.add_argument(
|
||||
"--environment",
|
||||
@@ -454,6 +460,11 @@ Examples:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user