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:
2026-08-10 12:04:50 +03:00
parent db255ea4e6
commit 4bc244c228
3 changed files with 215 additions and 51 deletions

View 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` со скриптом.

View File

@@ -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:-}"
;;
*)

View File

@@ -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)