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:
2026-09-10 10:57:18 +03:00
parent 1b9cb2b352
commit e1dcf7cf90
31 changed files with 3711 additions and 1711 deletions

View File

@@ -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_ваш_ключ \

View File

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

View File

@@ -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",