From 3caadde6ffd3e1a3d032626ad47462348cdf6ebc Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 28 Mar 2026 13:57:57 +0300 Subject: [PATCH] Fix production deployment issues --- backend/services/file_storage/main.py | 11 +- backend/services/main/main.py | 9 -- backend/services/main/routes/keys.py | 40 +------ backend/services/messaging/encryption.py | 3 +- backend/services/messaging/main.py | 17 ++- deployment/caddy/Caddyfile | 5 +- deployment/docker-compose.yml | 2 +- deployment/fromchat.service | 5 +- frontend/src/core/api/chats/dm.ts | 19 +++- frontend/src/core/api/user/auth.ts | 16 +++ frontend/src/pages/auth/LoginForm.tsx | 5 + frontend/src/pages/auth/RegisterForm.tsx | 5 + frontend/src/state/user.ts | 5 + scripts/deploy.sh | 133 ++++++++++++++++------- 14 files changed, 170 insertions(+), 105 deletions(-) diff --git a/backend/services/file_storage/main.py b/backend/services/file_storage/main.py index 35f9152..1b9ae59 100644 --- a/backend/services/file_storage/main.py +++ b/backend/services/file_storage/main.py @@ -92,13 +92,14 @@ from fastapi import UploadFile, File, HTTPException, Request, Depends from fastapi.responses import FileResponse from sqlalchemy.orm import Session -# File serving directories (matching main service structure) -FILES_BASE_DIR = Path("data/uploads/files") -FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" -FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" - # Base storage directories BASE_DIR = Path("files") +# Legacy upload layout (was data/uploads/files on monolith main under /app/data). +# Keep under BASE_DIR so Docker uses the file_storage volume (/app/files), not /app/data +# (different uid / optional mount → PermissionError on prod). +FILES_BASE_DIR = BASE_DIR / "data" / "uploads" / "files" +FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" +FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" FILES_DIR = BASE_DIR / "files" THUMBS_DIR = BASE_DIR / "thumbs" TMP_DIR = BASE_DIR / "tmp" diff --git a/backend/services/main/main.py b/backend/services/main/main.py index eb330cf..d0dddaa 100644 --- a/backend/services/main/main.py +++ b/backend/services/main/main.py @@ -319,15 +319,6 @@ async def health_check(): return {"status": "healthy", "service": "main"} -@app.get("/key/public") -async def key_public_proxy(): - """ - Proxy endpoint for messaging public key. In dev this calls the in-process function, - in production it will proxy to the external messaging service via the keys helper. - """ - return await keys.get_public_key() - - if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "8300")) diff --git a/backend/services/main/routes/keys.py b/backend/services/main/routes/keys.py index 13c984a..ccdfbbb 100644 --- a/backend/services/main/routes/keys.py +++ b/backend/services/main/routes/keys.py @@ -1,10 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, HTTPException import os import logging -import base64 -router = APIRouter(prefix="/api") +router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -58,37 +56,3 @@ async def get_public_key(): logger.error(f"Failed to fetch messaging public key via HTTP: {e}") raise HTTPException(status_code=502, detail="Failed to contact messaging service") - -@router.post("/key/invalidate") -async def invalidate_key(): - """ - Request messaging service to invalidate its current ephemeral key (rotate). - """ - messaging_module = _get_messaging_module() - if messaging_module: - try: - data = await messaging_module.invalidate_key() # type: ignore - return data - except Exception as e: - logger.error(f"Failed to invalidate key in in-process messaging module: {e}") - raise HTTPException(status_code=500, detail="Failed to invalidate messaging key") - - messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301") - url = f"{messaging_url.rstrip('/')}/key/invalidate" - try: - try: - import httpx - resp = httpx.post(url, timeout=5.0) - resp.raise_for_status() - return resp.json() - except Exception: - from urllib import request, error - import json - req = request.Request(url, method="POST") - with request.urlopen(req, timeout=5) as r: - body = r.read() - return json.loads(body) - except Exception as e: - logger.error(f"Failed to call messaging invalidate endpoint via HTTP: {e}") - raise HTTPException(status_code=502, detail="Failed to contact messaging service") - diff --git a/backend/services/messaging/encryption.py b/backend/services/messaging/encryption.py index 09a8066..7ee6810 100644 --- a/backend/services/messaging/encryption.py +++ b/backend/services/messaging/encryption.py @@ -137,7 +137,8 @@ def decrypt_transport_blob( ephemeral transport private key and the client's public key. Args: - client_public_key_b64: Sender public key in base64 (raw X25519). + client_public_key_b64: Client ephemeral public key in base64 (raw X25519), + the same key as used for the transport-encrypted message body. encrypted_blob: Raw bytes of `nonce || ciphertext`. ephemeral_private_key: Server ephemeral X25519 private key. nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305). diff --git a/backend/services/messaging/main.py b/backend/services/messaging/main.py index cc331c3..d67c886 100644 --- a/backend/services/messaging/main.py +++ b/backend/services/messaging/main.py @@ -6,8 +6,7 @@ providing compliance access while ensuring zero-knowledge storage of plaintext c API Endpoints: - GET /health: Health check -- GET /key/public: Get current ephemeral transport public key -- POST /key/invalidate: Rotate ephemeral keys +- GET /key/transport/public: Get current ephemeral transport public key - POST /process: Process encrypted message through envelope encryption pipeline """ @@ -203,7 +202,8 @@ class ProcessMessageRequest(BaseModel): class ProcessMessageWithFilesFile(BaseModel): """ - A single transport-encrypted file blob (base64 of nonce||ciphertext). + A single transport-encrypted file blob (base64 of nonce||ciphertext), + encrypted with the same ephemeral client key as the message body. """ encrypted_file_data_b64: str filename: str = "file" @@ -213,6 +213,8 @@ class ProcessMessageWithFilesRequest(ProcessMessageRequest): """ Process a transport-encrypted message and a list of transport-encrypted files using a single MEK for the whole envelope. + + Each file blob uses the same client_public_key_b64 / X25519 ephemeral pair as the message. """ files: list[ProcessMessageWithFilesFile] @@ -353,6 +355,9 @@ async def process_message_with_files( ): """ In-process helper: process message + transport-encrypted files with one MEK. + + File blobs must be encrypted with the same ephemeral client key as the message + (same client_public_key_b64), not the sender's long-term identity key. transport_files: list of {"encrypted_file_data_b64": str, "filename": str} """ private_key = _get_ephemeral_private_key() @@ -371,7 +376,7 @@ async def process_message_with_files( transport_blob = base64.b64decode(enc_b64) plaintext_files.append( decrypt_transport_blob( - client_public_key_b64=sender_public_key_b64, + client_public_key_b64=client_public_key_b64, encrypted_blob=transport_blob, ephemeral_private_key=private_key, ) @@ -393,8 +398,8 @@ async def process_message_with_files_http(request: ProcessMessageWithFilesReques """ Process an encrypted message and its files using a single MEK. - - Message transport layer is decrypted using the message client ephemeral key - - File transport layer is decrypted using the sender long-term public key + - Message and file transport layers use the same client ephemeral X25519 keypair + (client_public_key_b64); files are NaCl box ciphertexts to the server transport key - One MEK is generated and used to encrypt message + all files - MEK is wrapped for compliance, sender, and recipient (stored on DM envelope) """ diff --git a/deployment/caddy/Caddyfile b/deployment/caddy/Caddyfile index 38d17d5..cf1b3c7 100644 --- a/deployment/caddy/Caddyfile +++ b/deployment/caddy/Caddyfile @@ -1,7 +1,6 @@ fromchat.ru { - reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 { - lb_policy first - header_up X-Real-IP {remote_host} + reverse_proxy frontend:8301 { + header_up X-Real-IP {remote_host} } # Security headers diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 6e850ac..4bf9f64 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -172,7 +172,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" volumes: - - caddy_data:/root/site/certs + - caddy:/root/site/certs environment: XDG_DATA_HOME: /root/site/certs XDG_CONFIG_HOME: /root/site/certs diff --git a/deployment/fromchat.service b/deployment/fromchat.service index 7524770..1a930cf 100644 --- a/deployment/fromchat.service +++ b/deployment/fromchat.service @@ -10,11 +10,12 @@ StartLimitBurst=3 Type=simple User=root Group=root -ExecStart=/bin/docker compose up -ExecStop=/bin/docker compose down +ExecStart=/bin/bash -c "COMPOSE_PROFILES=production docker compose up --remove-orphans --force-recreate" +ExecStop=/bin/bash -c "COMPOSE_PROFILES=production docker compose down --remove-orphans" WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment Restart=always RestartSec=10 +StartLimitBurst=3 # Security settings NoNewPrivileges=true diff --git a/frontend/src/core/api/chats/dm.ts b/frontend/src/core/api/chats/dm.ts index dec7c9d..5049e6f 100644 --- a/frontend/src/core/api/chats/dm.ts +++ b/frontend/src/core/api/chats/dm.ts @@ -173,8 +173,21 @@ export async function sendWithFiles( const transportPublicKey = ub64(transportPublicKeyB64); - // Transport-encrypt message (client-side transport only; server will envelope-encrypt) - const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext || "", transportPublicKeyB64); + // One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob). + const ephemeralKeypair = tweetnacl.box.keyPair(); + const messagePlaintextBytes = new TextEncoder().encode(plaintext || ""); + const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength); + const messageCiphertext = tweetnacl.box( + messagePlaintextBytes, + messageNonce, + transportPublicKey, + ephemeralKeypair.secretKey + ); + const client_public_key_b64 = btoa( + String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[]) + ); + const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[])); + const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[])); const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : ""; @@ -197,7 +210,7 @@ export async function sendWithFiles( new Uint8Array(fileData), transportNonce, transportPublicKey, - keys.privateKey + ephemeralKeypair.secretKey ); const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length); transportEncryptedWithNonce.set(transportNonce); diff --git a/frontend/src/core/api/user/auth.ts b/frontend/src/core/api/user/auth.ts index adc656b..9f9e7a4 100644 --- a/frontend/src/core/api/user/auth.ts +++ b/frontend/src/core/api/user/auth.ts @@ -168,6 +168,22 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis return pair; } +/** + * When the client already has a keypair (e.g. from localStorage) but the server has no public key row, + * upload the public key. Covers failed uploads during login, DB resets, and legacy accounts. + */ +export async function syncPublicKeyToServerIfMissing(token: string): Promise { + const keys = getCurrentKeys(); + if (!keys?.publicKey?.length || !keys?.privateKey?.length) { + return; + } + const serverPk = await fetchPublicKey(token); + if (serverPk) { + return; + } + await uploadPublicKey(keys.publicKey, token); +} + export function restoreKeys() { currentPublicKey = ub64(localStorage.getItem("publicKey")!); currentPrivateKey = ub64(localStorage.getItem("privateKey")!); diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 17f96c1..4484f07 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -94,6 +94,11 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { await api.user.auth.ensureKeysOnLogin(password, data.token); } catch (e) { console.error("Key setup failed:", e); + try { + await api.user.auth.syncPublicKeyToServerIfMissing(data.token); + } catch (e2) { + console.error("Public key re-sync failed:", e2); + } } // Ensure WebSocket is connected and authenticated diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 81fdfa4..cdab23f 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -122,6 +122,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { await api.user.auth.ensureKeysOnLogin(password, data.token); } catch (e) { console.error("Key setup failed:", e); + try { + await api.user.auth.syncPublicKeyToServerIfMissing(data.token); + } catch (e2) { + console.error("Public key re-sync failed:", e2); + } } navigate("/chat"); diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index 8c8e036..fc2fd09 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -79,6 +79,11 @@ export const useUserStore = create((set) => ({ if (fullResponse.ok) { const user: User = await fullResponse.json(); api.user.auth.restoreKeys(); + try { + await api.user.auth.syncPublicKeyToServerIfMissing(token); + } catch (e) { + console.error("Public key sync to server failed:", e); + } if (user.suspended) { set({ diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 8c2f738..5323164 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -312,38 +312,39 @@ docker buildx use "$BUILDER_NAME" > /dev/null 2>&1 # Detect services step "Detecting services" cd "$DEPLOYMENT_DIR" +# Include profile-only services (e.g. caddy) so their images are built and pushed; otherwise prod keeps a stale Caddy image and ignores Caddyfile updates from rsync. +export COMPOSE_PROFILES=production SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null) if [ -z "$SERVICES" ]; then error "No services found in docker-compose.yml" fi +if ! command -v jq >/dev/null 2>&1; then + error "jq is required for deploy (e.g. brew install jq / sudo apt install jq)" + exit 1 +fi + +if ! COMPOSE_JSON=$(docker compose -f docker-compose.yml config --format json 2>/dev/null); then + error "docker compose config --format json failed (needs Docker Compose v2.10+)" + exit 1 +fi + BUILT_IMAGES=() for SERVICE in $SERVICES; do - HAS_BUILD=$(docker compose -f docker-compose.yml config 2>/dev/null | \ - grep -A 30 "^[[:space:]]*${SERVICE}:" | \ - grep -q "build:" && echo "yes" || echo "no") - - if [ "$HAS_BUILD" != "yes" ]; then + if ! jq -e --arg s "$SERVICE" '(.services[$s].build // false) | type == "object"' <<< "$COMPOSE_JSON" >/dev/null 2>&1; then continue fi - + IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest" - + substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..." - - BUILD_OUTPUT=$(docker compose -f docker-compose.yml config 2>/dev/null | \ - grep -A 15 "^[[:space:]]*${SERVICE}:" | \ - grep -A 10 "build:") - - DOCKERFILE_REL=$(echo "$BUILD_OUTPUT" | grep "dockerfile:" | \ - sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \ - tr -d '"' | tr -d "'" | xargs) - - CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \ - sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \ - tr -d '"' | tr -d "'" | xargs) + + DOCKERFILE_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.dockerfile // empty' <<< "$COMPOSE_JSON") + CONTEXT_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.context // empty' <<< "$COMPOSE_JSON") + # Multi-stage deployment/Dockerfile: without --target, the final stage (file_storage) is always tagged. + BUILD_TARGET=$(jq -r --arg s "$SERVICE" '.services[$s].build.target // empty' <<< "$COMPOSE_JSON") if [ -z "$CONTEXT_REL" ]; then CONTEXT_REL=".." @@ -377,12 +378,13 @@ for SERVICE in $SERVICES; do fi fi - if docker buildx build \ - --platform "$PLATFORM" \ - --file "$DOCKERFILE" \ - --tag "$IMAGE_TAG" \ - --load \ - "$BUILD_CONTEXT"; then + BUILDX_ARGS=(buildx build --platform "$PLATFORM" --file "$DOCKERFILE" --tag "$IMAGE_TAG" --load) + if [ -n "$BUILD_TARGET" ]; then + BUILDX_ARGS+=(--target "$BUILD_TARGET") + fi + BUILDX_ARGS+=("$BUILD_CONTEXT") + + if docker "${BUILDX_ARGS[@]}"; then echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}" BUILT_IMAGES+=("$IMAGE_TAG") echo "" @@ -413,12 +415,7 @@ COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev IMAGES=() for S in $COMPOSE_SERVICES; do - # Try to read explicit image: field from the compose config for this service - IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \ - grep -A5 "^[[:space:]]*${S}:" | \ - grep -m1 "image:" || true) - - IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true) + IMAGE_FROM_COMPOSE=$(jq -r --arg s "$S" '.services[$s].image // empty' <<< "$COMPOSE_JSON") if [ -n "$IMAGE_FROM_COMPOSE" ]; then IMAGES+=("$IMAGE_FROM_COMPOSE") @@ -520,11 +517,11 @@ step "Transferring deployment files" if [ -n "$SUDO_PASSWORD" ]; then ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1 set -e -echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment 2>/dev/null || true -echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment 2>/dev/null || true +echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment $DEPLOY_PATH/backend 2>/dev/null || true +echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment $DEPLOY_PATH/backend 2>/dev/null || true REMOTE_SUDO_SCRIPT else - ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment" > /dev/null 2>&1 || true + ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment $DEPLOY_PATH/backend && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment $DEPLOY_PATH/backend" > /dev/null 2>&1 || true fi # Copy deployment directory excluding gitignored files @@ -561,9 +558,71 @@ else warning ".env.prod not found in deployment directory" fi +# Firebase service account: bind-mounted at runtime (see docker-compose main volumes), never in the image (.dockerignore). +# If compose ever ran without this file on the host, Docker may have created a directory at this path — remove it before scp. +FIREBASE_CERT="$PROJECT_ROOT/backend/firebase-cert.json" +substep "Firebase service account (runtime bind-mount: backend/firebase-cert.json)..." +# ~ is not expanded inside variables on the remote shell (e.g. C="$D/..." with D=~/foo checks a bogus path). Resolve to an absolute path. +DEPLOY_PATH_ON_SERVER=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$SERVER" "eval echo $DEPLOY_PATH" 2>/dev/null || true) +if [ -z "$DEPLOY_PATH_ON_SERVER" ]; then + DEPLOY_PATH_ON_SERVER=$DEPLOY_PATH +fi +FIREBASE_REMOTE="$DEPLOY_PATH_ON_SERVER/backend/firebase-cert.json" +# Docker may have created this path as a root-owned directory; plain rm fails without sudo. +if [ -n "$SUDO_PASSWORD" ]; then + ssh "$SERVER" bash << REMOTE_FIREBASE_CLEANUP > /dev/null 2>&1 || true +set -e +D=$DEPLOY_PATH_ON_SERVER +C="\$D/backend/firebase-cert.json" +mkdir -p "\$D/backend" 2>/dev/null || true +if [ -d "\$C" ]; then + echo '$SUDO_PASSWORD' | sudo -S -p '' rm -rf "\$C" +fi +echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R "\$(whoami):\$(whoami)" "\$D/backend" 2>/dev/null || true +REMOTE_FIREBASE_CLEANUP +else + QBASE=$(printf '%q' "$DEPLOY_PATH_ON_SERVER") + ssh "$SERVER" "D=$QBASE; C=\"\$D/backend/firebase-cert.json\"; mkdir -p \"\$D/backend\"; if [ -d \"\$C\" ]; then sudo rm -rf \"\$C\" 2>/dev/null || rm -rf \"\$C\"; fi; sudo chown -R \$(whoami):\$(whoami) \"\$D/backend\" 2>/dev/null || true" > /dev/null 2>&1 || true +fi + +while true; do + if [ -f "$FIREBASE_CERT" ]; then + break + fi + if [ -d "$FIREBASE_CERT" ]; then + echo -e " ${YELLOW}⚠${NC} $FIREBASE_CERT is a directory. Delete it and save the Firebase service account JSON as a file at that exact path." + elif [ -e "$FIREBASE_CERT" ]; then + echo -e " ${YELLOW}⚠${NC} $FIREBASE_CERT exists but is not a regular file." + else + echo -e " ${YELLOW}⚠${NC} Missing $FIREBASE_CERT (Firebase service account JSON for FCM)." + fi + echo -e " ${CYAN}Fix this, then press Enter to check again (Ctrl+C to abort deploy).${NC}" + read -r _ +done + +substep "Copying backend/firebase-cert.json..." +FIREBASE_SCP_LOG="/tmp/fromchat-firebase-scp-$$.log" +if ! scp "$FIREBASE_CERT" "$SERVER:$FIREBASE_REMOTE" >"$FIREBASE_SCP_LOG" 2>&1; then + error "Failed to copy firebase-cert.json to server" + echo -e " ${YELLOW}Target:${NC} $SERVER:$FIREBASE_REMOTE" >&2 + if [ -s "$FIREBASE_SCP_LOG" ]; then + sed 's/^/ /' "$FIREBASE_SCP_LOG" >&2 + else + echo -e " ${YELLOW}(scp produced no output.)${NC}" >&2 + fi + rm -f "$FIREBASE_SCP_LOG" + exit 1 +fi +rm -f "$FIREBASE_SCP_LOG" +ssh "$SERVER" "chmod 600 $(printf '%q' "$FIREBASE_REMOTE") 2>/dev/null || true" +if ! ssh "$SERVER" "test -f $(printf '%q' "$FIREBASE_REMOTE")"; then + error "Server path is not a regular file after copy: $FIREBASE_REMOTE" + exit 1 +fi + # Deploy on server step "Deploying on server" -ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="$DEPLOY_PATH" bash << 'REMOTE_SCRIPT' +ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="${DEPLOY_PATH_ON_SERVER:-$DEPLOY_PATH}" bash << 'REMOTE_SCRIPT' set -e REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}" @@ -583,7 +642,7 @@ if [ -z "$REMOTE_DEPLOY_PATH" ]; then exit 1 fi -mkdir -p "$REMOTE_DEPLOY_PATH/deployment" +mkdir -p "$REMOTE_DEPLOY_PATH/deployment" "$REMOTE_DEPLOY_PATH/backend" cd "$REMOTE_DEPLOY_PATH/deployment" if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then @@ -594,7 +653,7 @@ if systemctl is-active --quiet fromchat; then sudo_cmd systemctl stop fromchat fi -docker compose down > /dev/null 2>&1 || true +COMPOSE_PROFILES=production docker compose down --remove-orphans > /dev/null 2>&1 || true sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service sudo_cmd systemctl daemon-reload