diff --git a/DEPLOY.md b/DEPLOY.md index 9b5dc66..a392da9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -79,29 +79,34 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml exec - Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге, поэтому на существующем контуре DDL нужно применить вручную (`make ch E=...`). -## Бэкап событий на домашний сервер +## Бэкап событий (забирает домашний сервер) -Сырые события живут на VPS ограниченное время: раз в сутки вчерашняя партиция -выгружается в `Parquet.gz`, передаётся на домашний сервер, импортируется в тамошний -ClickHouse и сверяется по количеству строк — **только после сверки** партиции старше -`BACKUP_GRACE_DAYS` (по умолчанию 7) удаляются на VPS. На VPS остаются агрегаты -(`events_daily`, `money_daily`). +Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает** +вчерашнюю партицию (`pull`), складывает её в `Parquet.gz`, импортирует в свой ClickHouse и +сверяет количество строк — **только после сверки** на VPS удаляются партиции старше +`BACKUP_GRACE_DAYS` (по умолчанию 7). На VPS остаются агрегаты (`events_daily`, `money_daily`). + +Входящий SSH на домашний сервер не нужен: он инициирует соединение сам. Разовая настройка: ```bash -cp env/backup.env.example env/backup.env # BACKUP_REMOTE, BACKUP_DIR, BACKUP_SSH_KEY, ... -# на домашнем сервере (в каталоге, куда кладутся архивы) разместить: -# scripts/clickhouse_import.sh -# clickhouse/init/001_events.sql -> как 001_events.sql рядом со скриптом +# на домашнем сервере +git clone ssh://git@:2222/mpakobecex/DispelEvilBot.git +cd DispelEvilBot +cp env/backup.env.example env/backup.env # VPS_SSH, VPS_PORT, VPS_PROJECT_DIR, VPS_SSH_KEY, BACKUP_DIR, CLICKHOUSE_* ``` -Проверочный прогон (без cron): `make backup E=test`. +Требования: на VPS — пользователь с доступом к `docker` и каталог с репозиторием +(`VPS_PROJECT_DIR`, там запускается `docker compose`); на домашнем сервере — `clickhouse-client` +и локальный ClickHouse. Настройка SSH-доступа — `deploy/ssh-access.md`. -Cron на VPS (вчерашний день уже закрыт, время московское): +Проверочный прогон (без cron): `make backup E=test` или `./scripts/clickhouse_backup.sh test`. + +Cron на домашнем сервере (вчерашний день уже закрыт, время московское): ```cron -0 4 * * * cd /opt/DispelEvilBot && ./scripts/clickhouse_backup.sh test >> /var/log/clickhouse-backup-test.log 2>&1 +0 4 * * * cd /srv/DispelEvilBot && ./scripts/clickhouse_backup.sh test >> /var/log/clickhouse-backup-test.log 2>&1 ``` Ошибка на любом шаге оставляет данные на VPS (партиции не удаляются) и пишет понятный лог. diff --git a/Makefile b/Makefile index b2744cb..106cc8b 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ help: > @echo " make check E=test проверка контура (скрипт)" > @echo " make psql E=test psql в БД контура" > @echo " make ch E=test clickhouse-client контура" -> @echo " make backup E=test бэкап вчерашних событий на домашний сервер" +> @echo " make backup E=test бэкап событий с VPS (запускать на домашнем сервере)" up: > $(COMPOSE) up -d diff --git a/README.md b/README.md index 6be22f4..980574e 100644 --- a/README.md +++ b/README.md @@ -93,18 +93,18 @@ pytest ## Бэкап событий -Сырые события ClickHouse ежедневно выгружаются архивом на домашний сервер (по cron), -на VPS остаются только агрегаты и последние дни сырых данных: +Сырые события ClickHouse ежедневно забираются с VPS домашним сервером (`pull`), на VPS +остаются только агрегаты и последние дни сырых данных: ```bash -make backup E=test # ./scripts/clickhouse_backup.sh test +make backup E=test # на домашнем сервере: ./scripts/clickhouse_backup.sh test ``` -Настройка и расписание — см. [`DEPLOY.md`](DEPLOY.md). +Настройка, cron и SSH-доступ — см. [`DEPLOY.md`](DEPLOY.md). ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.6`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.7`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/common/version.py b/common/version.py index 6b552e8..4eac622 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.3.6" +__version__ = "0.3.7" diff --git a/docs/events.md b/docs/events.md index 1423737..6ea2aa1 100644 --- a/docs/events.md +++ b/docs/events.md @@ -32,10 +32,10 @@ DDL — `clickhouse/init/001_events.sql` (применяется при перв | Агрегаты | VPS | постоянно | | Полная история | домашний сервер, свой ClickHouse | архивы `events_YYYY-MM-DD.parquet.gz` (ежедневно) | -Каждую ночь (по Москве) `scripts/clickhouse_backup.sh` выгружает вчерашнюю партицию, -передаёт её на домашний сервер, импортирует и **сверяет количество строк**; только после -успешной сверки на VPS удаляются партиции старше грейса. Ошибка бэкапа данные не удаляет. -Подробности — `DEPLOY.md`. +Каждую ночь (по Москве) домашний сервер **сам забирает** вчерашнюю партицию с VPS (`pull` +по ssh), импортирует её в свой ClickHouse и **сверяет количество строк**; только после +успешной сверки он просит VPS удалить партиции старше грейса. Ошибка бэкапа данные не +удаляет. Подробности — `DEPLOY.md` и `deploy/ssh-access.md`. ## Уведомления в чат логов diff --git a/env/backup.env.example b/env/backup.env.example index b89df68..85da79e 100644 --- a/env/backup.env.example +++ b/env/backup.env.example @@ -1,28 +1,32 @@ -# Параметры ежедневного бэкапа событий ClickHouse с VPS на домашний сервер. +# Параметры бэкапа событий ClickHouse (pull: домашний сервер забирает данные с VPS). # Скопируйте в env/backup.env (env/*.env в git не попадает) и заполните. # -# Используется скриптами: -# scripts/clickhouse_backup.sh (запускается на VPS по cron, см. DEPLOY.md) -# scripts/clickhouse_import.sh (лежит на домашнем сервере) +# Запускается НА ДОМАШНЕМ СЕРВЕРЕ по cron: +# scripts/clickhouse_backup.sh test|prod +# +# SSH-доступ к VPS настраивается отдельно (инструкция — deploy/ssh-access.md). -# Куда передавать архивы: пользователь@хост и каталог на домашнем сервере. -BACKUP_REMOTE=user@home.example +# --- VPS --- +# user@host (хост указывайте без скобок; порт задаётся отдельно) +VPS_SSH=deploy@vps.example +VPS_PORT=27 +# Каталог с репозиторием на VPS (там запускается docker compose) +VPS_PROJECT_DIR=/opt/DispelEvilBot +# Приватный ключ для ssh на VPS (публичная часть добавлена в authorized_keys на VPS) +VPS_SSH_KEY=/root/.ssh/id_ed25519_vps + +# --- Домашний сервер --- +# Куда складывать архивы events_YYYY-MM-DD.parquet.gz BACKUP_DIR=/srv/dispelevilbot/events -# Приватный ключ для ssh/rsync (без пароля; на домашнем сервере — соответствующий public key). -BACKUP_SSH_KEY=/root/.ssh/dispelevilbot_backup - -# Сколько дней сырых событий оставлять на VPS (остальное удаляется после успешной сверки). +# --- Хранение --- +# Сколько дней сырых событий остаётся на VPS (остальное удаляется после успешной сверки) BACKUP_GRACE_DAYS=7 - -# Таймзона расчёта «вчерашнего» дня. +# Таймзона расчёта «вчерашнего» дня BACKUP_TZ=Europe/Moscow -# Как запускать импорт на домашнем сервере (путь к scripts/clickhouse_import.sh). -REMOTE_IMPORT_SCRIPT=./clickhouse_import.sh - -# ClickHouse домашнего сервера. Пароль лучше без кавычек и пробелов — он передаётся по ssh. -REMOTE_CLICKHOUSE_HOST=localhost -REMOTE_CLICKHOUSE_PORT=9000 -REMOTE_CLICKHOUSE_USER=default -REMOTE_CLICKHOUSE_PASSWORD= +# --- ClickHouse домашнего сервера (сюда импортируются архивы) --- +CLICKHOUSE_HOST=localhost +CLICKHOUSE_PORT=9000 +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= diff --git a/scripts/clickhouse_backup.sh b/scripts/clickhouse_backup.sh index 6a4c621..3511552 100644 --- a/scripts/clickhouse_backup.sh +++ b/scripts/clickhouse_backup.sh @@ -1,32 +1,28 @@ #!/usr/bin/env bash -# Ежедневный бэкап сырых событий ClickHouse с VPS на домашний сервер. +# Бэкап сырых событий ClickHouse: домашний сервер забирает данные с VPS (pull). +# +# Запускается НА ДОМАШНЕМ СЕРВЕРЕ по cron. Входящий SSH на домашний сервер не нужен: +# скрипт сам ходит на VPS по ключу и запускает там docker compose. # # Что делает: -# 1) выгружает партицию «вчерашнего» дня из analytics.events в Parquet.gz; -# 2) передаёт файл на домашний сервер (rsync) и импортирует его в тамошний ClickHouse; -# 3) сверяет количество строк на VPS и на домашнем сервере; -# 4) только после успешной сверки удаляет на VPS партиции старше BACKUP_GRACE_DAYS. +# 1) по ssh выгружает на VPS вчерашнюю партицию analytics.events в Parquet; +# 2) складывает events_YYYY-MM-DD.parquet.gz в BACKUP_DIR; +# 3) импортирует архив в локальный ClickHouse (идемпотентно, scripts/clickhouse_import.sh); +# 4) сверяет количество строк на VPS и локально; +# 5) только после успешной сверки просит VPS удалить партиции старше BACKUP_GRACE_DAYS. # -# Ошибка на любом шаге оставляет данные на VPS и возвращает ненулевой код. +# Ошибка на любом шаге оставляет данные на VPS (партиции не удаляются) и возвращает ненулевой код. # -# Запуск (на VPS, из каталога проекта): -# ./scripts/clickhouse_backup.sh test # или prod +# Использование: +# ./scripts/clickhouse_backup.sh test # или prod # make backup E=test # -# Настройки (env/<контур>.env + env/backup.env, см. env/backup.env.example): -# CLICKHOUSE_* доступ к ClickHouse контура -# BACKUP_REMOTE user@host домашнего сервера -# BACKUP_DIR каталог архива на домашнем сервере -# BACKUP_SSH_KEY приватный ключ для ssh/rsync -# BACKUP_GRACE_DAYS сколько дней сырых событий оставлять на VPS (по умолчанию 7) -# BACKUP_TZ таймзона расчёта «вчера» (по умолчанию Europe/Moscow) +# Настройки — env/backup.env (см. env/backup.env.example). set -euo pipefail E="${1:-test}" -ENV_FILE="env/${E}.env" -BACKUP_ENV="env/backup.env" -COMPOSE="docker compose -p ${E} --env-file ${ENV_FILE} -f docker-compose.prod.yml" +BACKUP_ENV="${BACKUP_ENV:-env/backup.env}" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } fail() { @@ -34,76 +30,72 @@ fail() { exit 1 } -[ -f "$ENV_FILE" ] || fail "нет файла окружения ${ENV_FILE}" +[ -f "$BACKUP_ENV" ] || fail "нет файла настроек ${BACKUP_ENV} (скопируйте env/backup.env.example)" # shellcheck disable=SC1090 -set -a && . "./${ENV_FILE}" && set +a -if [ -f "$BACKUP_ENV" ]; then - # shellcheck disable=SC1090 - set -a && . "./${BACKUP_ENV}" && set +a -fi +set -a && . "./${BACKUP_ENV}" && set +a -: "${BACKUP_REMOTE:?задайте BACKUP_REMOTE в env/backup.env}" +: "${VPS_SSH:?задайте VPS_SSH в env/backup.env}" : "${BACKUP_DIR:?задайте BACKUP_DIR в env/backup.env}" -BACKUP_SSH_KEY="${BACKUP_SSH_KEY:-$HOME/.ssh/id_rsa}" +VPS_PORT="${VPS_PORT:-22}" +VPS_PROJECT_DIR="${VPS_PROJECT_DIR:?задайте VPS_PROJECT_DIR в env/backup.env}" +VPS_SSH_KEY="${VPS_SSH_KEY:-$HOME/.ssh/id_ed25519_vps}" +VPS_COMPOSE_FILE="${VPS_COMPOSE_FILE:-docker-compose.prod.yml}" BACKUP_GRACE_DAYS="${BACKUP_GRACE_DAYS:-7}" BACKUP_TZ="${BACKUP_TZ:-Europe/Moscow}" -REMOTE_IMPORT_SCRIPT="${REMOTE_IMPORT_SCRIPT:-./clickhouse_import.sh}" -REMOTE_CLICKHOUSE_HOST="${REMOTE_CLICKHOUSE_HOST:-localhost}" -REMOTE_CLICKHOUSE_PORT="${REMOTE_CLICKHOUSE_PORT:-9000}" -REMOTE_CLICKHOUSE_USER="${REMOTE_CLICKHOUSE_USER:-default}" -REMOTE_CLICKHOUSE_PASSWORD="${REMOTE_CLICKHOUSE_PASSWORD:-}" -SSH=(ssh -i "$BACKUP_SSH_KEY" -o BatchMode=yes "$BACKUP_REMOTE") +IMPORT_SCRIPT="${IMPORT_SCRIPT:-$(dirname "$0")/clickhouse_import.sh}" +SCHEMA_FILE="${SCHEMA_FILE:-$(dirname "$0")/../clickhouse/init/001_events.sql}" -ch() { - $COMPOSE exec -T clickhouse clickhouse-client \ - --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" "$@" +SSH=(ssh -i "$VPS_SSH_KEY" -p "$VPS_PORT" -o BatchMode=yes -o ServerAliveInterval=30 "$VPS_SSH") + +# Запустить команду на VPS. Пароль ClickHouse читается из окружения контейнера, +# поэтому клиент вызывается через `sh -c` уже внутри контейнера. +vps_ch() { + "${SSH[@]}" bash -s "$VPS_PROJECT_DIR" "$E" "$VPS_COMPOSE_FILE" "$@" <<'REMOTE' +set -euo pipefail +project_dir="$1"; contour="$2"; compose_file="$3"; shift 3 +cd "$project_dir" +exec docker compose -p "$contour" --env-file "env/$contour.env" -f "$compose_file" exec -T clickhouse \ + sh -c 'clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" "$@"' sh "$@" +REMOTE } DAY="$(TZ="$BACKUP_TZ" date -d 'yesterday' '+%Y-%m-%d')" PARTITION="${DAY//-/}" GRACE_CUTOFF="$(TZ="$BACKUP_TZ" date -d "${BACKUP_GRACE_DAYS} days ago" '+%Y%m%d')" -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT -log "Контур ${E}: бэкап событий за ${DAY} (грейс ${BACKUP_GRACE_DAYS} дней, порог ${GRACE_CUTOFF})" +log "Бэкап событий контура ${E} за ${DAY} (грейс ${BACKUP_GRACE_DAYS} дней, порог ${GRACE_CUTOFF})" -if ! $COMPOSE ps --services --status running 2>/dev/null | grep -qx clickhouse; then - fail "контейнер clickhouse контура ${E} не запущен" -fi +mkdir -p "$BACKUP_DIR" +ARCHIVE="${BACKUP_DIR}/events_${DAY}.parquet.gz" -ROWS_LOCAL="$(ch --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate('${DAY}')" | tr -d '[:space:]')" -log "На VPS строк за ${DAY}: ${ROWS_LOCAL}" -if [ "$ROWS_LOCAL" = "0" ]; then +ROWS_VPS="$(vps_ch --param_day="$DAY" --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate({day:String})" | tr -d '[:space:]')" +log "На VPS строк за ${DAY}: ${ROWS_VPS}" +if [ "$ROWS_VPS" = "0" ]; then log "ВНИМАНИЕ: за ${DAY} событий нет — партиция пустая, продолжаю (сверка 0 = 0)" fi -ARCHIVE="${TMP_DIR}/events_${DAY}.parquet.gz" log "Выгружаю партицию в ${ARCHIVE}" -ch --query "SELECT * FROM analytics.events WHERE toDate(event_time) = toDate('${DAY}') FORMAT Parquet" | gzip -9 >"$ARCHIVE" -[ -s "$ARCHIVE" ] || fail "не удалось выгрузить архив ${DAY}" +vps_ch --param_day="$DAY" --query "SELECT * FROM analytics.events WHERE toDate(event_time) = toDate({day:String}) FORMAT Parquet" | + gzip -9 >"${ARCHIVE}.part" +[ -s "${ARCHIVE}.part" ] || fail "не удалось выгрузить архив ${DAY}" +mv "${ARCHIVE}.part" "$ARCHIVE" log "Размер архива: $(du -h "$ARCHIVE" | cut -f1)" -log "Передаю архив на ${BACKUP_REMOTE}:${BACKUP_DIR}" -rsync -az -e "ssh -i ${BACKUP_SSH_KEY} -o BatchMode=yes" "$ARCHIVE" "${BACKUP_REMOTE}:${BACKUP_DIR}/" - log "Импортирую архив в ClickHouse домашнего сервера" -ROWS_REMOTE="$("${SSH[@]}" \ - "BACKUP_DIR='${BACKUP_DIR}' CLICKHOUSE_HOST='${REMOTE_CLICKHOUSE_HOST}' CLICKHOUSE_PORT='${REMOTE_CLICKHOUSE_PORT}' \ -CLICKHOUSE_USER='${REMOTE_CLICKHOUSE_USER}' CLICKHOUSE_PASSWORD='${REMOTE_CLICKHOUSE_PASSWORD}' \ -'${REMOTE_IMPORT_SCRIPT}' '${DAY}'" | tail -n1 | tr -d '[:space:]')" -log "На домашнем сервере строк за ${DAY}: ${ROWS_REMOTE}" +ROWS_HOME="$(BACKUP_DIR="$BACKUP_DIR" SCHEMA_FILE="$SCHEMA_FILE" "$IMPORT_SCRIPT" "$DAY" | tail -n1 | tr -d '[:space:]')" +log "На домашнем сервере строк за ${DAY}: ${ROWS_HOME}" -[ "$ROWS_LOCAL" = "$ROWS_REMOTE" ] || fail "расхождение строк (VPS ${ROWS_LOCAL}, домашний ${ROWS_REMOTE}) — партиции на VPS не удаляю" +[ "$ROWS_VPS" = "$ROWS_HOME" ] || fail "расхождение строк (VPS ${ROWS_VPS}, домашний ${ROWS_HOME}) — партиции на VPS не удаляю" log "Удаляю на VPS партиции старше ${GRACE_CUTOFF}" -OLDER="$(ch --query "SELECT DISTINCT partition FROM system.parts WHERE database='analytics' AND table='events' AND active AND partition < '${GRACE_CUTOFF}'")" -if [ -z "$OLDER" ]; then +DROPPED="$(vps_ch --query "SELECT DISTINCT partition FROM system.parts WHERE database='analytics' AND table='events' AND active AND partition < '${GRACE_CUTOFF}'")" +if [ -z "$DROPPED" ]; then log "Удалять нечего" else - for partition in $OLDER; do + for partition in $DROPPED; do log "DROP PARTITION ID '${partition}'" - ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${partition}'" + vps_ch --query "ALTER TABLE analytics.events DROP PARTITION ID '${partition}'" done fi -log "Готово: события за ${DAY} в архиве, на VPS остались агрегаты и последние ${BACKUP_GRACE_DAYS} дней сырых событий" +log "Готово: события за ${DAY} в архиве ${ARCHIVE}, на VPS остались агрегаты и последние ${BACKUP_GRACE_DAYS} дней сырых событий" diff --git a/scripts/clickhouse_import.sh b/scripts/clickhouse_import.sh index 3bb29af..b68bbd4 100644 --- a/scripts/clickhouse_import.sh +++ b/scripts/clickhouse_import.sh @@ -1,16 +1,16 @@ #!/usr/bin/env bash # Импорт дневного архива событий в ClickHouse домашнего сервера. # -# Запускается на домашнем сервере (обычно по ssh из scripts/clickhouse_backup.sh). -# Рядом должны лежать этот скрипт и канонический DDL кликхаус-схемы -# (clickhouse/init/001_events.sql из репозитория). +# Запускается на домашнем сервере; обычно вызывается из scripts/clickhouse_backup.sh, +# но можно и вручную — например, чтобы повторно залить архив после сбоя. # -# Использование: ./clickhouse_import.sh 2026-01-01 +# Использование: +# ./scripts/clickhouse_import.sh 2026-01-01 # В stdout печатается количество строк загруженного дня (для сверки), диагностика — в stderr. # # Настройки (окружение): -# BACKUP_DIR каталог, куда складываются архивы events_YYYY-MM-DD.parquet.gz -# SCHEMA_FILE путь к DDL схемы (по умолчанию ./001_events.sql рядом со скриптом) +# BACKUP_DIR каталог с архивами events_YYYY-MM-DD.parquet.gz +# SCHEMA_FILE DDL схемы (по умолчанию clickhouse/init/001_events.sql рядом с репозиторием) # CLICKHOUSE_CLIENT бинарь клиента (по умолчанию clickhouse-client) # CLICKHOUSE_HOST/PORT/USER/PASSWORD доступ к ClickHouse домашнего сервера @@ -21,7 +21,7 @@ PARTITION="${DAY//-/}" ARCHIVE_DIR="${BACKUP_DIR:-.}" ARCHIVE="${ARCHIVE_DIR}/events_${DAY}.parquet.gz" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SCHEMA_FILE="${SCHEMA_FILE:-${SCRIPT_DIR}/001_events.sql}" +SCHEMA_FILE="${SCHEMA_FILE:-${SCRIPT_DIR}/../clickhouse/init/001_events.sql}" CLIENT="${CLICKHOUSE_CLIENT:-clickhouse-client}" @@ -40,7 +40,7 @@ ch() { exit 1 } [ -f "$SCHEMA_FILE" ] || { - log "нет файла схемы ${SCHEMA_FILE} (скопируйте clickhouse/init/001_events.sql на домашний сервер)" + log "нет файла схемы ${SCHEMA_FILE} (канонический DDL — clickhouse/init/001_events.sql)" exit 1 } @@ -55,4 +55,4 @@ log "Импортирую ${ARCHIVE} в analytics.events" gzip -dc "$ARCHIVE" | ch --query "INSERT INTO analytics.events FORMAT Parquet" log "Импорт завершён" -ch --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate('${DAY}')" +ch --param_day="$DAY" --query "SELECT count() FROM analytics.events WHERE toDate(event_time) = toDate({day:String})" diff --git a/tests/test_version.py b/tests/test_version.py index f84e902..2f52b2c 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.3.6" + assert __version__ == "0.3.7"