fix(maintenance): INV_7 routes decomposition, UX contract-drift repair, RBAC/L2 test gaps closed
- decompose _routes.py (947 lines) into a 27-line facade + 5 handler modules (events/preview/start/end/settings), all <=400; extract _chart_layout from _chart_manager (441->381+78); contract IDs preserved verbatim, 9 routes registered in original order
- close RBAC FR-015 invariant gap: tests/api/test_maintenance_routes_rbac.py — 403 denied + 401 unauth on all 9 endpoints with exact guards (root cause of the blind spot: conftest MaintenanceRouteEnv overrode permission closures with lambda: None)
- repair 4 UX contract drifts: EventsTable empty-state + expandedEventIds invariant aligned to implementation (backend terminal-events expansion is intended per MaintenanceEventStateStatuses @POST); Badge phantom loading state removed (INV_9); SettingsPanel fields disabled during Saving implemented per contract
- add 34 L2 component tests (EventsTable/Badge/SettingsPanel) covering declared @UX_STATE/@UX_TEST/@UX_RECOVERY
- split oversized test files (709/686/624 -> all <=520, collected counts identical)
- test hygiene: RootTransaction is_active guard removes SAWarning in shared postgres fixture; AsyncMock create_task coroutine leak fixed in scheduler tests (zero RuntimeWarnings)
- examples/maintenance actualized against current API: optional environment_id with PROD fan-out (batch response), 422 no-PROD-target, GET events/{id}/dashboards, settings field list
Verified: isolated worktree (HEAD + this diff) 729 backend tests passed; frontend 109 passed; ruff clean; anchors balanced; index rebuilt (0 warnings)
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
|
||||
- **start** — создать событие обслуживания для таблиц (1..100) с окном
|
||||
начала/окончания, сообщением баннера и опциональным авто-завершением
|
||||
(`--auto-end` / `auto_end=true`);
|
||||
(`--auto-end` / `auto_end=true`); `environment_id` опционален — если его
|
||||
не указать, старт выполняется во ВСЕХ PROD-окружениях (fan-out, batch-ответ);
|
||||
- **end** — завершить конкретное событие (снять баннеры);
|
||||
- **end-all** — аварийно завершить ВСЕ активные события в окружении.
|
||||
|
||||
@@ -36,6 +37,7 @@
|
||||
| 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/events/{id}/dashboards` | `maintenance` READ | Дашборды события (для завершённых — полная история) |
|
||||
| GET | `/api/maintenance/dashboard-banners` | `maintenance` READ | Баннеры по дашбордам |
|
||||
| POST | `/api/maintenance/preview-dashboards` | `maintenance` READ | Какие дашборды затронуты |
|
||||
| GET/PUT | `/api/maintenance/settings` | READ / admin | Настройки обслуживания |
|
||||
@@ -53,16 +55,62 @@
|
||||
}
|
||||
```
|
||||
|
||||
- `tables` — обязателен (1..100); `start_time`, `environment_id` — обязательны.
|
||||
- `tables` — обязателен (1..100); `start_time` — обязателен.
|
||||
- `environment_id` — **опционален**. Если указан — старт в одном окружении
|
||||
(одиночный ответ `{task_id, maintenance_id, status}`). Если опущен —
|
||||
**fan-out**: обслуживание стартует во ВСЕХ PROD-окружениях, ответ 202
|
||||
приходит в batch-форме:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending",
|
||||
"events": [
|
||||
{
|
||||
"environment_id": "ss-prod-a",
|
||||
"task_id": "task-123",
|
||||
"maintenance_id": "event-123",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"environment_id": "ss-prod-b",
|
||||
"task_id": "task-456",
|
||||
"maintenance_id": "event-456",
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `auto_end` — по умолчанию `false`; если `true` и задан `end_time`,
|
||||
планировщик сам завершит обслуживание ровно в `end_time`. **Без `auto_end`
|
||||
событие останется активным до ручного завершения командой `end`.**
|
||||
- Ошибки: `400` (невалидные данные, `end_time <= start_time`, >100 таблиц),
|
||||
`401`, `403`, `404` (неизвестное окружение), `409` — идемпотентность
|
||||
`401`, `403`, `404` (неизвестное окружение при явном `environment_id`),
|
||||
`422` (`environment_id` опущен, но PROD-окружения не сконфигурированы —
|
||||
fan-out стартовать негде), `409` — идемпотентность
|
||||
(`status: "already_active"` — такое же окно уже активно).
|
||||
- Идемпотентность скоупится **по `environment_id`**: `409` возвращается только
|
||||
для явного одиночного старта; в batch-режиме (fan-out) повтор по конкретному
|
||||
окружению отражается как `status: "already_active"` в соответствующем элементе
|
||||
`events[]` внутри ответа 202 — без `409`.
|
||||
- `end` идемпотентен: повторное завершение уже завершённого события даёт
|
||||
`202` с `status: "already_completed"` (считается успехом).
|
||||
|
||||
### Настройки (`PUT /settings`, admin)
|
||||
|
||||
Тело `PUT /api/maintenance/settings` — все поля опциональны (частичное
|
||||
обновление):
|
||||
|
||||
- `target_environment_id` — окружение по умолчанию;
|
||||
- `display_timezone` — таймзона отображения;
|
||||
- `date_format` — формат дат;
|
||||
- `banner_template` — шаблон баннера;
|
||||
- `default_message` — сообщение по умолчанию (макс. 500 симв.);
|
||||
- `banner_height` — высота баннера (1..200);
|
||||
- `dashboard_scope` — область дашбордов;
|
||||
- `excluded_dashboard_ids` — исключённые дашборды (`[int]`);
|
||||
- `forced_dashboard_ids` — принудительно затронутые дашборды (`[int]`).
|
||||
|
||||
## Использование
|
||||
|
||||
### Требования
|
||||
@@ -87,6 +135,9 @@ export SS_TOOLS_API_KEY=ssk_ваш_ключ
|
||||
# Обслуживание на 4 часа (без авто-завершения)
|
||||
./maintenance-api-bash.sh start public.messages 4 ss-dev
|
||||
|
||||
# Fan-out: без environment — старт во ВСЕХ PROD-окружениях (batch-ответ)
|
||||
./maintenance-api-bash.sh start public.messages 4
|
||||
|
||||
# Несколько таблиц, сообщение, автоснятие баннера через 4 часа
|
||||
./maintenance-api-bash.sh start public.messages,public.users 4 ss-prod \
|
||||
"Плановый ETL" --auto-end
|
||||
@@ -113,6 +164,12 @@ python maintenance-api-python.py start \
|
||||
--environment ss-dev --duration-hours 4 \
|
||||
--message "Плановый ETL" --auto-end
|
||||
|
||||
# Fan-out: без --environment — старт во ВСЕХ PROD-окружениях (batch-ответ)
|
||||
python maintenance-api-python.py start \
|
||||
--api-key ssk_ваш_ключ \
|
||||
--base-url https://superset-tools.example.com \
|
||||
--tables public.messages --duration-hours 4
|
||||
|
||||
# Завершить конкретное событие
|
||||
python maintenance-api-python.py end \
|
||||
--api-key ssk_ваш_ключ \
|
||||
|
||||
@@ -35,19 +35,33 @@
|
||||
# auto_end: bool По умолчанию false. Если true и задан end_time,
|
||||
# планировщик сам завершит обслуживание в end_time.
|
||||
# message: string|optional Текст баннера (макс. 500 симв.).
|
||||
# environment_id: string ОБЯЗАТЕЛЬНО. Целевое окружение (ss-dev, ss-prod…).
|
||||
# environment_id: string ОПЦИОНАЛЬНО. Целевое окружение (ss-dev, ss-prod…).
|
||||
# Если НЕ указано (опущено) — обслуживание
|
||||
# стартует во ВСЕХ PROD-окружениях (fan-out),
|
||||
# а ответ 202 приходит в batch-форме с events[].
|
||||
#
|
||||
# Ответ 202:
|
||||
# { "task_id": "…", "maintenance_id": "…", "status": "pending" }
|
||||
# - одиночная форма (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 — неверный/отозванный API-ключ;
|
||||
# 403 — недостаточно прав;
|
||||
# 404 — неизвестное environment_id;
|
||||
# 409 — идемпотентность: такое же (tables, start_time, end_time) уже активно:
|
||||
# 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 по каждому окружению.
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# 2) POST /api/maintenance/{maintenance_id}/end — завершить конкретное событие
|
||||
@@ -72,6 +86,10 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
# Прочие (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 — настройки обслуживания
|
||||
@@ -95,9 +113,13 @@
|
||||
# КОМАНДЫ:
|
||||
# Запустить обслуживание на 4 часа:
|
||||
# ./maintenance-api-bash.sh start public.messages 4 ss-dev
|
||||
# Fan-out во ВСЕХ PROD-окружениях (без environment; batch-ответ с events[]):
|
||||
# ./maintenance-api-bash.sh start public.messages 4
|
||||
# Несколько таблиц через запятую, с сообщением и автоснятием баннера:
|
||||
# ./maintenance-api-bash.sh start public.messages,public.users 4 ss-prod \
|
||||
# "Плановый ETL" --auto-end
|
||||
# Пропустить environment, но задать message — передайте «-» третьим аргументом:
|
||||
# ./maintenance-api-bash.sh start public.messages 4 - "Плановый ETL"
|
||||
# Завершить конкретное событие:
|
||||
# ./maintenance-api-bash.sh end m-abc123
|
||||
# Аварийно снять ВСЕ баннеры в окружении ss-dev:
|
||||
@@ -225,6 +247,11 @@ api_call() {
|
||||
echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2
|
||||
return 1
|
||||
;;
|
||||
422)
|
||||
warn "Unprocessable request (422): no PROD environments configured" >&2
|
||||
echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
fail "Unexpected HTTP $http_code: $(echo "$body" | head -c 500)"
|
||||
;;
|
||||
@@ -236,18 +263,26 @@ api_call() {
|
||||
cmd_start() {
|
||||
local tables="$1"
|
||||
local duration_hours="${2:-4}"
|
||||
local environment_id="$3"
|
||||
local environment_id="${3:-}"
|
||||
local message="${4:-}"
|
||||
|
||||
# "-" is the explicit "skip this positional" marker → omit environment_id
|
||||
# from the payload → server fans the start out to ALL PROD environments.
|
||||
if [[ "$environment_id" == "-" ]]; then
|
||||
environment_id=""
|
||||
fi
|
||||
|
||||
local start_time
|
||||
local end_time
|
||||
start_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
end_time=$(date -u -d "+${duration_hours} hours" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
|
||||
|| date -u -v "+${duration_hours}H" +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Build JSON payload
|
||||
# Build JSON payload — environment_id is OPTIONAL:
|
||||
# present → single-environment start; omitted → PROD fan-out (batch response).
|
||||
local payload
|
||||
payload=$(cat <<EOF
|
||||
if [[ -n "$environment_id" ]]; then
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"tables": [$(echo "$tables" | sed 's/[^,]*/"&"/g')],
|
||||
"start_time": "$start_time",
|
||||
@@ -255,6 +290,15 @@ cmd_start() {
|
||||
"environment_id": "$environment_id"
|
||||
EOF
|
||||
)
|
||||
else
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"tables": [$(echo "$tables" | sed 's/[^,]*/"&"/g')],
|
||||
"start_time": "$start_time",
|
||||
"end_time": "$end_time"
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
if [[ "$AUTO_END" -eq 1 ]]; then
|
||||
payload="$payload"$',\n "auto_end": true'
|
||||
info "Auto-end enabled: banner will be removed at ${end_time}"
|
||||
@@ -264,11 +308,30 @@ EOF
|
||||
fi
|
||||
payload="$payload"$'\n}'
|
||||
|
||||
info "Starting maintenance: tables=${tables}, env=${environment_id}, ${duration_hours}h"
|
||||
info "Starting maintenance: tables=${tables}, env=${environment_id:-ALL PROD (fan-out)}, ${duration_hours}h"
|
||||
info "Window: ${start_time} → ${end_time}"
|
||||
|
||||
local response
|
||||
response=$(api_call POST "/api/maintenance/start" "$payload") || return 1
|
||||
|
||||
# Batch shape detection: `.events` present → fan-out start across PROD envs.
|
||||
if command -v jq >/dev/null 2>&1 && echo "$response" | jq -e '.events' >/dev/null 2>&1; then
|
||||
local env_count
|
||||
env_count=$(echo "$response" | jq '.events | length')
|
||||
ok "Maintenance batch accepted (${env_count} PROD environment(s))"
|
||||
local env_id maintenance_id item_status
|
||||
while IFS='|' read -r env_id maintenance_id item_status; do
|
||||
if [[ "$item_status" == "already_active" ]]; then
|
||||
info "${env_id} / ${maintenance_id} / ${item_status} (idempotent)"
|
||||
else
|
||||
ok "${env_id} / ${maintenance_id} / ${item_status}"
|
||||
fi
|
||||
done < <(echo "$response" | jq -r '.events[] | [.environment_id, .maintenance_id, .status] | join("|")')
|
||||
echo "$response" | jq . 2>/dev/null || echo "$response"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Single shape (explicit environment_id)
|
||||
local event_id
|
||||
event_id=$(echo "$response" | jq -r '.maintenance_id' 2>/dev/null || echo "unknown")
|
||||
local task_id
|
||||
@@ -339,13 +402,19 @@ set -- "${ARGS[@]}"
|
||||
case "${1:-help}" in
|
||||
start)
|
||||
check_auth
|
||||
if [[ $# -lt 3 ]]; then
|
||||
echo "Usage: $0 start <tables> [duration_hours] <environment> [message] [--auto-end]"
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 start <tables> [duration_hours] [environment|-] [message] [--auto-end]"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 start public.messages 4 ss-dev"
|
||||
echo " $0 start public.messages 4 # fan-out: all PROD environments"
|
||||
echo " $0 start public.messages 4 - \"Scheduled ETL\" # '-' skips environment → fan-out"
|
||||
echo " $0 start public.messages,public.users 4 ss-prod \"Scheduled ETL\" --auto-end"
|
||||
echo ""
|
||||
echo "environment is OPTIONAL: omit it (or pass '-') to fan the start out to ALL"
|
||||
echo "PROD environments — the API answers with a batch body { status, events[] }."
|
||||
echo "422 is returned when environment is omitted and no PROD env is configured."
|
||||
echo ""
|
||||
echo "--auto-end: also pass auto_end=true so the banner is removed automatically"
|
||||
echo " at end_time. Without it, end_time is informational only."
|
||||
exit 1
|
||||
@@ -369,8 +438,10 @@ case "${1:-help}" in
|
||||
superset-tools Maintenance CLI
|
||||
|
||||
Usage:
|
||||
$0 start <tables> [hours=4] <environment> [message] [--auto-end]
|
||||
$0 start <tables> [hours=4] [environment|-] [message] [--auto-end]
|
||||
Start maintenance on tables (comma-separated).
|
||||
environment is OPTIONAL: omit it (or pass '-') to start in ALL PROD
|
||||
environments (fan-out) — the response is a batch { status, events[] }.
|
||||
--auto-end also removes the banner automatically at end_time (API param auto_end=true).
|
||||
|
||||
$0 end <event-id>
|
||||
@@ -381,6 +452,7 @@ Usage:
|
||||
|
||||
Examples:
|
||||
$0 start public.messages 4 ss-dev
|
||||
$0 start public.messages 4 # fan-out: all PROD environments
|
||||
$0 start public.messages 4 ss-dev "ETL refresh" --auto-end
|
||||
$0 end m-abc123
|
||||
$0 end-all ss-dev
|
||||
|
||||
@@ -9,19 +9,28 @@ superset-tools API Key authentication.
|
||||
Requirements: Python 3.10+, requests (pip install requests)
|
||||
|
||||
Usage:
|
||||
# Start maintenance
|
||||
# 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
|
||||
|
||||
@@ -62,16 +71,32 @@ Usage:
|
||||
auto_end bool По умолчанию false. Если true и задан end_time,
|
||||
планировщик сам завершит обслуживание в end_time.
|
||||
message string Опционально. Текст баннера (макс. 500 симв.).
|
||||
environment_id string ОБЯЗАТЕЛЬНО. Целевое окружение (ss-dev и т.п.).
|
||||
Ответ 202: { "task_id": "...", "maintenance_id": "...", "status": "pending" }
|
||||
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;
|
||||
409 — идемпотентность (такое же tables/start_time/end_time уже активно):
|
||||
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 | Тело: отсутствует.
|
||||
@@ -91,6 +116,10 @@ Usage:
|
||||
|
||||
--- Прочие (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 — настройки обслуживания
|
||||
@@ -111,6 +140,9 @@ Usage:
|
||||
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 \
|
||||
@@ -152,7 +184,7 @@ 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,
|
||||
environment_id: str | None, duration_hours: int = 4,
|
||||
message: str | None = None, auto_end: bool = False,
|
||||
timeout: int = 30) -> dict:
|
||||
"""
|
||||
@@ -162,7 +194,9 @@ def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
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")
|
||||
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
|
||||
@@ -171,15 +205,22 @@ def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
API response dict with task_id and maintenance_id
|
||||
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.
|
||||
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()
|
||||
@@ -189,8 +230,9 @@ def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
"tables": tables,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"environment_id": environment_id,
|
||||
}
|
||||
if environment_id is not None:
|
||||
payload["environment_id"] = environment_id
|
||||
if auto_end:
|
||||
payload["auto_end"] = True
|
||||
if message:
|
||||
@@ -198,7 +240,7 @@ def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
|
||||
print(f"[maintenance] Starting maintenance for tables: {tables}")
|
||||
print(f"[maintenance] Window: {start_time} → {end_time}")
|
||||
print(f"[maintenance] Environment: {environment_id}")
|
||||
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)")
|
||||
|
||||
@@ -211,6 +253,21 @@ def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
|
||||
if response.status_code == 202:
|
||||
result = response.json()
|
||||
if "events" in result:
|
||||
# Batch shape: fan-out start across all PROD environments.
|
||||
print(f"[maintenance] ✅ 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] ℹ️ {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] ✅ Event created (id={result['maintenance_id']})")
|
||||
print(f"[maintenance] Task: {result.get('task_id', 'N/A')}")
|
||||
return result
|
||||
@@ -228,6 +285,10 @@ def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
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]}")
|
||||
|
||||
@@ -358,6 +419,9 @@ Examples:
|
||||
%(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
|
||||
|
||||
@@ -392,9 +456,11 @@ Examples:
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
required=False,
|
||||
default=None,
|
||||
dest="environment_id",
|
||||
help="Superset environment ID (e.g. 'ss-dev', 'ss-prod')",
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user