From 93aef6e18bdbf7f502f32a4f13c9f44e4c038b0f0e662d61cbaf7481fb47b844 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 23 Apr 2026 18:04:14 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D1=8E=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2;=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D0=B9-=D1=86=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 54 +++-- deploy_shine-PWA.sh | 47 ++++- doc/instructions/ui-deploy-targets.md | 38 ++++ shine-UI/js/app.js | 53 ++++- shine-UI/js/pages/chat-view.js | 18 +- shine-UI/js/state.js | 92 ++++++++- .../IT_DeployBackupCleanAndRunRemoteMain.java | 190 ++++++++++++++++++ 7 files changed, 457 insertions(+), 35 deletions(-) create mode 100644 doc/instructions/ui-deploy-targets.md create mode 100644 src/test/java/test/it/IT_DeployBackupCleanAndRunRemoteMain.java diff --git a/build.gradle b/build.gradle index feb869f..a4cbe93 100644 --- a/build.gradle +++ b/build.gradle @@ -174,10 +174,10 @@ tasks.named('build') { tasks.register('deployServer', JavaExec) { group = "!!deployment" - description = "Build → upload to server → clean remote data → restart service → run IT against server" + description = "Build → upload to server → restart service (без удаления БД, без IT тестов)" classpath = sourceSets.test.runtimeClasspath - mainClass = "test.it.IT_DeployRestartAndRunRemoteMain" + mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain" // можно переопределить при запуске: // ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=... @@ -185,13 +185,30 @@ tasks.register('deployServer', JavaExec) { systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247") systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user") systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server") - systemProperty "it.remoteDataDir", System.getProperty("it.remoteDataDir", "/home/user/docker/shine-server/data") systemProperty "it.service", System.getProperty("it.service", "shine-server") systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar") - systemProperty "it.wsUri", System.getProperty("it.wsUri", "wss://shineup.me/ws") - systemProperty "it.login", System.getProperty("it.login", "anya24") + dependsOn testClasses +} +tasks.register('deployServerWithBackupCleanAndTests', JavaExec) { + group = "!!deployment" + description = "DANGER: deploy + backup data + clean data + restart + run IT tests (с обязательным подтверждением)" + + classpath = sourceSets.test.runtimeClasspath + mainClass = "test.it.IT_DeployBackupCleanAndRunRemoteMain" + + dependsOn shadowJar + systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247") + systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user") + systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server") + systemProperty "it.remoteDataDir", System.getProperty("it.remoteDataDir", "/home/user/docker/shine-server/data") + systemProperty "it.remoteBackupDir", System.getProperty("it.remoteBackupDir", "/home/user/docker/shine-server/backup") + systemProperty "it.service", System.getProperty("it.service", "shine-server") + systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar") + systemProperty "it.wsUri", System.getProperty("it.wsUri", "wss://shineup.me/ws") + + standardInput = System.in dependsOn testClasses } @@ -214,21 +231,22 @@ tasks.register('deployServerNoCleanNoTests', JavaExec) { dependsOn testClasses } -tasks.register('deployWEB', Exec) { - group = "!!deployment" - description = "Deploy WEB via deploy_shine-PWA.sh" - - workingDir = rootDir - commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath +def registerWebDeployTask = { String taskName, String target, String descriptionText -> + tasks.register(taskName, Exec) { + group = "!!deployment" + description = descriptionText + workingDir = rootDir + commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath, target + } } -tasks.register('deployAll') { - group = "!!deployment" - description = "Deploy server and WEB" - - dependsOn tasks.named('deployServer') - dependsOn tasks.named('deployWEB') -} +registerWebDeployTask('deployWEB_Production', 'prod', 'Deploy WEB (production: shineup.me)') +registerWebDeployTask('deployWEB_ui_1', 'ui_1', 'Deploy WEB (ui-1.shineup.me)') +registerWebDeployTask('deployWEB_ui_2', 'ui_2', 'Deploy WEB (ui-2.shineup.me)') +registerWebDeployTask('deployWEB_ui_3', 'ui_3', 'Deploy WEB (ui-3.shineup.me)') +registerWebDeployTask('deployWEB_DrygMira', 'ui_drygmira', 'Deploy WEB (ui-drygmira.shineup.me)') +registerWebDeployTask('deployWEB_Milana', 'ui_milana', 'Deploy WEB (ui-milana.shineup.me)') +registerWebDeployTask('deployWEB_Aidar', 'ui_aidar', 'Deploy WEB (ui-aidar.shineup.me)') tasks.register('startLocal', Exec) { group = "!!run" diff --git a/deploy_shine-PWA.sh b/deploy_shine-PWA.sh index 6294e5f..2b3337f 100755 --- a/deploy_shine-PWA.sh +++ b/deploy_shine-PWA.sh @@ -2,12 +2,13 @@ set -euo pipefail SRC_DIR="shine-UI" -REMOTE_HOST="root@194.87.0.247" -REMOTE_DIR="/home/user/docker/caddyFile/sites/shine-UI" +REMOTE_HOST="${REMOTE_HOST:-user@10.147.20.7}" +REMOTE_BASE_DIR="${REMOTE_BASE_DIR:-/home/user/docker/caddyFile/sites}" BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)" VERSION_FILE="VERSION.properties" export BUILD_VERSION TMP_DIR="$(mktemp -d)" +TARGET="${1:-prod}" CLIENT_VERSION="dev" if [[ -f "$VERSION_FILE" ]]; then @@ -18,6 +19,45 @@ if [[ -f "$VERSION_FILE" ]]; then fi export CLIENT_VERSION +TARGET_DIR="shine-UI" +TARGET_URL="https://shineup.me" +case "$TARGET" in + prod|production|main|shineup|shineup.me|shine-UI) + TARGET_DIR="shine-UI" + TARGET_URL="https://shineup.me" + ;; + ui_1|ui-1|1|shine-UI_1) + TARGET_DIR="shine-UI_1" + TARGET_URL="https://ui-1.shineup.me" + ;; + ui_2|ui-2|2|shine-UI_2) + TARGET_DIR="shine-UI_2" + TARGET_URL="https://ui-2.shineup.me" + ;; + ui_3|ui-3|3|shine-UI_3) + TARGET_DIR="shine-UI_3" + TARGET_URL="https://ui-3.shineup.me" + ;; + ui_drygmira|ui-drygmira|drygmira|shine-UI_drygmira) + TARGET_DIR="shine-UI_drygmira" + TARGET_URL="https://ui-drygmira.shineup.me" + ;; + ui_milana|ui-milana|milana|shine-UI_milana) + TARGET_DIR="shine-UI_milana" + TARGET_URL="https://ui-milana.shineup.me" + ;; + ui_aidar|ui-aidar|aidar|shine-UI_aidar) + TARGET_DIR="shine-UI_aidar" + TARGET_URL="https://ui-aidar.shineup.me" + ;; + *) + echo "ERROR: unknown target '$TARGET'" >&2 + echo "Available targets: prod, ui_1, ui_2, ui_3, ui_drygmira, ui_milana, ui_aidar" >&2 + exit 1 + ;; +esac +REMOTE_DIR="${REMOTE_BASE_DIR}/${TARGET_DIR}" + cleanup() { rm -rf "$TMP_DIR" } @@ -29,6 +69,7 @@ if [[ ! -d "$SRC_DIR" ]]; then fi echo "==> Preparing staged UI copy with build version: $BUILD_VERSION" +echo "==> Deploy target: $TARGET_URL ($TARGET_DIR)" rsync -a "$SRC_DIR"/ "$TMP_DIR"/ INDEX_FILE="$TMP_DIR/index.html" @@ -49,4 +90,4 @@ ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" echo "==> Syncing staged files to $REMOTE_DIR" rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/ -echo "Всё хорошо" +echo "Всё хорошо: $TARGET_URL" diff --git a/doc/instructions/ui-deploy-targets.md b/doc/instructions/ui-deploy-targets.md new file mode 100644 index 0000000..da7a034 --- /dev/null +++ b/doc/instructions/ui-deploy-targets.md @@ -0,0 +1,38 @@ +# Деплой UI по окружениям (Caddy sites) + +## Куда деплоит скрипт +- Базовая директория на сервере: `/home/user/docker/caddyFile/sites` +- По умолчанию деплой идёт на production (`shineup.me`) в папку `shine-UI`. + +## Gradle-команды +- Продакшен (`shineup.me`): `./gradlew deployWEB_Production` +- `ui-1.shineup.me`: `./gradlew deployWEB_ui_1` +- `ui-2.shineup.me`: `./gradlew deployWEB_ui_2` +- `ui-3.shineup.me`: `./gradlew deployWEB_ui_3` +- `ui-drygmira.shineup.me`: `./gradlew deployWEB_DrygMira` +- `ui-milana.shineup.me`: `./gradlew deployWEB_Milana` +- `ui-aidar.shineup.me`: `./gradlew deployWEB_Aidar` + +## Прямой запуск скрипта +- `bash deploy_shine-PWA.sh prod` +- `bash deploy_shine-PWA.sh ui_1` +- `bash deploy_shine-PWA.sh ui_2` +- `bash deploy_shine-PWA.sh ui_3` +- `bash deploy_shine-PWA.sh ui_drygmira` +- `bash deploy_shine-PWA.sh ui_milana` +- `bash deploy_shine-PWA.sh ui_aidar` + +Также поддерживаются алиасы с дефисом: +- `bash deploy_shine-PWA.sh ui-1` +- `bash deploy_shine-PWA.sh ui-2` +- `bash deploy_shine-PWA.sh ui-3` +- `bash deploy_shine-PWA.sh ui-drygmira` +- `bash deploy_shine-PWA.sh ui-milana` +- `bash deploy_shine-PWA.sh ui-aidar` + +## Поддержка переопределения +- `REMOTE_HOST` (по умолчанию `user@10.147.20.7`) +- `REMOTE_BASE_DIR` (по умолчанию `/home/user/docker/caddyFile/sites`) + +Пример: +`REMOTE_HOST=user@10.147.20.7 REMOTE_BASE_DIR=/home/user/docker/caddyFile/sites bash deploy_shine-PWA.sh ui_2` diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index ebdd154..765d0de 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -105,6 +105,7 @@ const appShellEl = document.querySelector('.app-shell'); const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); +const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; let currentCleanup = null; let pingIntervalId = null; @@ -119,6 +120,7 @@ let connectionCheckInFlight = false; let wsSessionRestoreInFlight = null; let uiUpdateReloadScheduled = false; let pwaUpdateCheckAttempted = false; +let uiVersionCheckInFlight = false; setClientErrorTransport((payload) => authService.reportClientError(payload)); initPwaInstallPromptHandling(); @@ -235,16 +237,51 @@ async function triggerImmediateConnectionRetry() { await checkConnectionHealth(); } -function checkAndReloadIfUiUpdated(remoteHashRaw) { +function extractBuildHashFromHtml(htmlText) { + const html = String(htmlText || ''); + if (!html) return ''; + const match = html.match(UI_BUILD_HASH_PATTERN); + return String(match?.[1] || '').trim(); +} + +async function fetchCurrentHostUiBuildHash() { + try { + const url = `./index.html?build_probe=${Date.now()}`; + const response = await fetch(url, { cache: 'no-store' }); + if (!response?.ok) return ''; + const html = await response.text(); + return extractBuildHashFromHtml(html); + } catch { + return ''; + } +} + +async function checkAndReloadIfUiUpdated(remoteHashRaw) { const remoteHash = String(remoteHashRaw || '').trim(); if (!remoteHash || !CURRENT_BUILD_HASH || remoteHash === CURRENT_BUILD_HASH) return; + if (uiVersionCheckInFlight) return; - scheduleUiReload({ - source: 'version-check', - message: `Обнаружена новая версия UI (через Ping): ${CURRENT_BUILD_HASH} -> ${remoteHash}`, - delayMs: 600, - activateWaitingWorker: true, - }); + uiVersionCheckInFlight = true; + try { + const latestHostHash = await fetchCurrentHostUiBuildHash(); + if (!latestHostHash || latestHostHash === CURRENT_BUILD_HASH) { + addAppLogEntry({ + level: 'info', + source: 'version-check', + message: `Ping сообщил другую версию UI (${remoteHash}), но текущий хост всё ещё отдаёт ${CURRENT_BUILD_HASH}. Reload пропущен, чтобы избежать цикла.`, + }); + return; + } + + scheduleUiReload({ + source: 'version-check', + message: `Обнаружена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash} (ping: ${remoteHash})`, + delayMs: 600, + activateWaitingWorker: true, + }); + } finally { + uiVersionCheckInFlight = false; + } } async function refreshServiceWorkers({ activateWaitingWorker = false } = {}) { @@ -313,7 +350,7 @@ async function checkConnectionHealth() { } const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); const remoteUiBuildHash = pingResp?.payload?.uiBuildHash || pingResp?.uiBuildHash || ''; - checkAndReloadIfUiUpdated(remoteUiBuildHash); + void checkAndReloadIfUiUpdated(remoteUiBuildHash); await tryUpdatePwaOnFirstConnectedPing(); setConnectionStatus('connected'); } catch { diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 42fe4a2..1373cab 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -68,6 +68,17 @@ function resolveDeliveryStatus(msg) { return '…'; } +function scrollToLatestMessage(list) { + if (!list) return; + const apply = () => { + list.scrollTop = list.scrollHeight; + }; + apply(); + window.requestAnimationFrame(apply); + window.setTimeout(apply, 0); + window.setTimeout(apply, 120); +} + function renderLog(list, chatId) { list.innerHTML = ''; const messages = getChatMessages(chatId); @@ -110,7 +121,7 @@ function renderLog(list, chatId) { bubble.append(textNode, metaNode); list.append(bubble); }); - list.scrollTop = list.scrollHeight; + scrollToLatestMessage(list); markChatRead(chatId); } @@ -240,10 +251,11 @@ export function render({ navigate, route }) { } }); - renderLog(log, chatId); - void sendReadReceiptsForVisible(chatId); wrap.append(log, form); screen.append(wrap); + renderLog(log, chatId); + window.requestAnimationFrame(() => scrollToLatestMessage(log)); + void sendReadReceiptsForVisible(chatId); return screen; } async function sendReadReceiptsForVisible(chatId) { diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index bef7896..4e4b6fa 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -263,8 +263,69 @@ export const authService = new AuthService(state.entrySettings.shineServer); let onSessionReset = null; let onSessionAuthorized = null; +function parseMessageTimeFromKey(rawKey) { + const value = String(rawKey || '').trim(); + if (!value) return 0; + const parts = value.split('|'); + if (parts.length < 4) return 0; + const timeMs = Number(parts[2] || 0); + if (!Number.isFinite(timeMs) || timeMs <= 0) return 0; + return Math.trunc(timeMs); +} + +function resolveChatMessageTimeMs(row) { + const fromBaseKey = parseMessageTimeFromKey(row?.baseKey); + if (fromBaseKey > 0) return fromBaseKey; + + const fromMessageKey = parseMessageTimeFromKey(row?.messageKey); + if (fromMessageKey > 0) return fromMessageKey; + + const fromCreatedAt = Number(row?.createdAtMs || row?.ts || 0); + if (Number.isFinite(fromCreatedAt) && fromCreatedAt > 0) return Math.trunc(fromCreatedAt); + + const tempId = String(row?.tempId || '').trim(); + if (tempId.startsWith('tmp-')) { + const parts = tempId.split('-'); + const ts = Number(parts[1] || 0); + if (Number.isFinite(ts) && ts > 0) return Math.trunc(ts); + } + + return 0; +} + +function stableMessageOrderKey(row) { + const key = String(row?.messageKey || '').trim(); + if (key) return `mk:${key}`; + const tmp = String(row?.tempId || '').trim(); + if (tmp) return `tmp:${tmp}`; + const base = String(row?.baseKey || '').trim(); + if (base) return `bk:${base}`; + const text = String(row?.text || '').trim(); + return `txt:${text}`; +} + +function sortChatMessagesInPlace(chatId) { + const list = getChatMessages(chatId); + list.sort((a, b) => { + const ta = resolveChatMessageTimeMs(a); + const tb = resolveChatMessageTimeMs(b); + if (ta !== tb) return ta - tb; + + const ka = stableMessageOrderKey(a); + const kb = stableMessageOrderKey(b); + const byKey = ka.localeCompare(kb, 'ru'); + if (byKey !== 0) return byKey; + + if ((a?.from || '') !== (b?.from || '')) { + return (a?.from === 'in') ? -1 : 1; + } + return 0; + }); +} + function persistMessageRecord(chatId, row) { if (!chatId || !row?.messageKey) return; + const resolvedTs = resolveChatMessageTimeMs(row); void putStoredMessage({ messageKey: row.messageKey, chatId, @@ -278,13 +339,14 @@ function persistMessageRecord(chatId, row) { secondTick: Boolean(row.secondTick), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), - ts: Date.now(), + ts: resolvedTs > 0 ? resolvedTs : Date.now(), }).catch(() => {}); } export async function hydrateMessagesFromStore() { try { const rows = await listStoredMessages(); + const touchedChats = new Set(); rows .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0)) .forEach((row) => { @@ -293,6 +355,7 @@ export async function hydrateMessagesFromStore() { if (!chatId || !messageKey) return; if (state.knownMessageKeys[messageKey]) return; state.knownMessageKeys[messageKey] = true; + touchedChats.add(chatId); getChatMessages(chatId).push({ from: row.from === 'out' ? 'out' : 'in', text: String(row.text || ''), @@ -305,8 +368,10 @@ export async function hydrateMessagesFromStore() { secondTick: Boolean(row.secondTick), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), + createdAtMs: Number(row.ts || 0), }); }); + touchedChats.forEach((chatId) => sortChatMessagesInPlace(chatId)); } catch { // ignore broken storage } @@ -322,7 +387,15 @@ export function getChatMessages(chatId) { export function addChatMessage(chatId, text) { const message = text.trim(); if (!message) return; - getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false }); + getChatMessages(chatId).push({ + from: 'out', + text: message, + firstTick: false, + secondTick: false, + unread: false, + createdAtMs: Date.now(), + }); + sortChatMessagesInPlace(chatId); } export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'system' } = {}) { @@ -335,7 +408,9 @@ export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'syste unread: from === 'in', firstTick: from !== 'in', secondTick: false, + createdAtMs: Date.now(), }); + sortChatMessagesInPlace(chatId); } @@ -344,7 +419,14 @@ export function addIncomingMessage(chatId, text, messageId = '') { if (!msg) return false; if (messageId && state.incomingDedup[messageId]) return false; if (messageId) state.incomingDedup[messageId] = true; - getChatMessages(chatId).push({ from: 'in', text: msg, messageId, unread: true }); + getChatMessages(chatId).push({ + from: 'in', + text: msg, + messageId, + unread: true, + createdAtMs: Date.now(), + }); + sortChatMessagesInPlace(chatId); return true; } @@ -359,7 +441,9 @@ export function addOutgoingPendingMessage(chatId, text) { firstTick: false, secondTick: false, unread: false, + createdAtMs: Date.now(), }); + sortChatMessagesInPlace(chatId); return tempId; } @@ -377,6 +461,7 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) state.knownMessageKeys[messageKey] = true; persistMessageRecord(chatId, row); } + sortChatMessagesInPlace(chatId); }); } @@ -455,6 +540,7 @@ export function addSignedMessageToChat({ secondTick: false, }; getChatMessages(chatId).push(row); + sortChatMessagesInPlace(chatId); persistMessageRecord(chatId, row); return true; } diff --git a/src/test/java/test/it/IT_DeployBackupCleanAndRunRemoteMain.java b/src/test/java/test/it/IT_DeployBackupCleanAndRunRemoteMain.java new file mode 100644 index 0000000..88350f7 --- /dev/null +++ b/src/test/java/test/it/IT_DeployBackupCleanAndRunRemoteMain.java @@ -0,0 +1,190 @@ +package test.it; + +import test.it.runner.IT_RunAllMain; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.util.Enumeration; +import java.util.Objects; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Деплой сервера с ПОЛНОЙ очисткой data, но только после: + * 1) явного интерактивного подтверждения; + * 2) бэкапа data в backup/data_YYYYMMDD-HHMMSS-SSS. + * + * После запуска сервиса выполняет полный IT-прогон. + */ +public class IT_DeployBackupCleanAndRunRemoteMain { + + private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247"); + private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user"); + + private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server"); + private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar"; + private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data"); + private static final String REMOTE_BACKUP_DIR = System.getProperty("it.remoteBackupDir", REMOTE_DIR + "/backup"); + + private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server"); + private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar"); + private static final String WS_URI_SERVER = System.getProperty("it.wsUri", "wss://shineup.me/ws"); + + public static void main(String[] args) { + requireDangerousConfirmation(); + + boolean serviceStopped = false; + boolean serviceStarted = false; + try { + sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true"); + serviceStopped = true; + + validateLocalFatJarOrThrow(LOCAL_JAR); + sshStrict("mkdir -p " + q(REMOTE_DIR)); + scpStrict(LOCAL_JAR, REMOTE_JAR + ".new"); + verifyRemoteNewJarOrThrow(REMOTE_JAR + ".new"); + sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR)); + + backupAndCleanRemoteDataOrThrow(); + + sshStrict("sudo systemctl start " + SERVICE_NAME); + serviceStarted = true; + waitRemotePort7070(); + + System.setProperty("it.wsUri", WS_URI_SERVER); + int failed = IT_RunAllMain.runAll(); + System.exit(failed); + } catch (Throwable error) { + // На случай сбоя стараемся поднять сервис обратно. + if (serviceStopped && !serviceStarted) { + try { + sshStrict("sudo systemctl start " + SERVICE_NAME + " || true"); + } catch (Throwable ignored) { + // ignore + } + } + throw error; + } + } + + private static void requireDangerousConfirmation() { + String warning = "" + + "\n============================================================\n" + + "ВНИМАНИЕ: будет очищена серверная БД после бэкапа.\n" + + "HOST: " + REMOTE_USER + "@" + REMOTE_HOST + "\n" + + "DATA: " + REMOTE_DATA + "\n" + + "BACKUP DIR: " + REMOTE_BACKUP_DIR + "\n" + + "Для продолжения введите: DELETE\n" + + "============================================================\n"; + System.out.print(warning); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + String answer = reader.readLine(); + if (!"DELETE".equals(String.valueOf(answer).trim())) { + throw new RuntimeException("Операция отменена: подтверждение не получено."); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Не удалось прочитать подтверждение из stdin", e); + } + } + + private static void backupAndCleanRemoteDataOrThrow() { + String cmd = "" + + "mkdir -p " + q(REMOTE_DATA) + " " + q(REMOTE_BACKUP_DIR) + " && " + + "stamp=$(date +%Y%m%d-%H%M%S)-$(date +%3N) && " + + "backup=" + q(REMOTE_BACKUP_DIR) + "/data_$stamp && " + + "cp -a " + q(REMOTE_DATA) + " \"$backup\" && " + + "echo backup_dir=$backup && " + + "find " + q(REMOTE_DATA) + " -mindepth 1 -maxdepth 1 -exec rm -rf {} +"; + sshStrict(cmd); + } + + private static void waitRemotePort7070() { + for (int i = 0; i < 50; i++) { + int code = ssh("ss -ltnp | grep -q ':7070'"); + if (code == 0) return; + sleepMs(200); + } + throw new RuntimeException("Remote port 7070 did not start in time on " + REMOTE_HOST); + } + + private static void sshStrict(String remoteCmd) { + int code = ssh(remoteCmd); + if (code != 0) throw new RuntimeException("SSH command failed (" + code + "): " + remoteCmd); + } + + private static int ssh(String remoteCmd) { + String cmd = "ssh " + REMOTE_USER + "@" + REMOTE_HOST + " " + q("bash -lc " + q(remoteCmd)); + return sh(cmd); + } + + private static void scpStrict(String local, String remote) { + Objects.requireNonNull(local); + Objects.requireNonNull(remote); + int code = sh("scp -p " + q(local) + " " + REMOTE_USER + "@" + REMOTE_HOST + ":" + q(remote)); + if (code != 0) throw new RuntimeException("SCP failed (" + code + ")"); + } + + private static int sh(String cmd) { + try { + Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start(); + return p.waitFor(); + } catch (Exception e) { + throw new RuntimeException("Command error: " + cmd, e); + } + } + + private static String q(String s) { + return "'" + s.replace("'", "'\"'\"'") + "'"; + } + + private static void sleepMs(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void validateLocalFatJarOrThrow(String localJarPath) { + File jar = new File(localJarPath); + if (!jar.isFile()) { + throw new RuntimeException("Local jar not found: " + localJarPath); + } + long size = jar.length(); + if (size < 10L * 1024L * 1024L) { + throw new RuntimeException("Local jar is too small for fat-jar: " + size + " bytes (" + localJarPath + ")"); + } + try (JarFile jf = new JarFile(jar)) { + boolean hasJetty = false; + boolean hasBc = false; + Enumeration entries = jf.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + if (!hasJetty && "org/eclipse/jetty/server/Handler.class".equals(name)) hasJetty = true; + if (!hasBc && "org/bouncycastle/jce/provider/BouncyCastleProvider.class".equals(name)) hasBc = true; + if (hasJetty && hasBc) break; + } + if (!hasJetty || !hasBc) { + throw new RuntimeException( + "Local jar doesn't look like fat-jar (missing deps). hasJetty=" + hasJetty + ", hasBC=" + hasBc + ); + } + } catch (Exception e) { + throw new RuntimeException("Failed to inspect local jar: " + localJarPath, e); + } + } + + private static void verifyRemoteNewJarOrThrow(String remoteJarNewPath) { + String cmd = "test -f " + q(remoteJarNewPath) + " && " + + "sz=$(stat -c %s " + q(remoteJarNewPath) + ") && " + + "echo remote_new_size=$sz && test \"$sz\" -ge 10485760"; + int code = ssh(cmd); + if (code != 0) { + throw new RuntimeException("Remote uploaded jar is missing or too small: " + remoteJarNewPath); + } + } +}