Fix production deployment issues

This commit is contained in:
2026-03-28 13:57:57 +03:00
Unverified
parent 30ab4d9190
commit 3caadde6ff
14 changed files with 170 additions and 105 deletions
+6 -5
View File
@@ -92,13 +92,14 @@ from fastapi import UploadFile, File, HTTPException, Request, Depends
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session 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 storage directories
BASE_DIR = Path("files") 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" FILES_DIR = BASE_DIR / "files"
THUMBS_DIR = BASE_DIR / "thumbs" THUMBS_DIR = BASE_DIR / "thumbs"
TMP_DIR = BASE_DIR / "tmp" TMP_DIR = BASE_DIR / "tmp"
-9
View File
@@ -319,15 +319,6 @@ async def health_check():
return {"status": "healthy", "service": "main"} 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__": if __name__ == "__main__":
import uvicorn import uvicorn
port = int(os.getenv("PORT", "8300")) port = int(os.getenv("PORT", "8300"))
+2 -38
View File
@@ -1,10 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, HTTPException
from typing import Dict, Any
import os import os
import logging import logging
import base64
router = APIRouter(prefix="/api") router = APIRouter()
logger = logging.getLogger("uvicorn.error") 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}") logger.error(f"Failed to fetch messaging public key via HTTP: {e}")
raise HTTPException(status_code=502, detail="Failed to contact messaging service") 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")
+2 -1
View File
@@ -137,7 +137,8 @@ def decrypt_transport_blob(
ephemeral transport private key and the client's public key. ephemeral transport private key and the client's public key.
Args: 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`. encrypted_blob: Raw bytes of `nonce || ciphertext`.
ephemeral_private_key: Server ephemeral X25519 private key. ephemeral_private_key: Server ephemeral X25519 private key.
nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305). nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305).
+11 -6
View File
@@ -6,8 +6,7 @@ providing compliance access while ensuring zero-knowledge storage of plaintext c
API Endpoints: API Endpoints:
- GET /health: Health check - GET /health: Health check
- GET /key/public: Get current ephemeral transport public key - GET /key/transport/public: Get current ephemeral transport public key
- POST /key/invalidate: Rotate ephemeral keys
- POST /process: Process encrypted message through envelope encryption pipeline - POST /process: Process encrypted message through envelope encryption pipeline
""" """
@@ -203,7 +202,8 @@ class ProcessMessageRequest(BaseModel):
class ProcessMessageWithFilesFile(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 encrypted_file_data_b64: str
filename: str = "file" filename: str = "file"
@@ -213,6 +213,8 @@ class ProcessMessageWithFilesRequest(ProcessMessageRequest):
""" """
Process a transport-encrypted message and a list of transport-encrypted files Process a transport-encrypted message and a list of transport-encrypted files
using a single MEK for the whole envelope. 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] files: list[ProcessMessageWithFilesFile]
@@ -353,6 +355,9 @@ async def process_message_with_files(
): ):
""" """
In-process helper: process message + transport-encrypted files with one MEK. 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} transport_files: list of {"encrypted_file_data_b64": str, "filename": str}
""" """
private_key = _get_ephemeral_private_key() private_key = _get_ephemeral_private_key()
@@ -371,7 +376,7 @@ async def process_message_with_files(
transport_blob = base64.b64decode(enc_b64) transport_blob = base64.b64decode(enc_b64)
plaintext_files.append( plaintext_files.append(
decrypt_transport_blob( decrypt_transport_blob(
client_public_key_b64=sender_public_key_b64, client_public_key_b64=client_public_key_b64,
encrypted_blob=transport_blob, encrypted_blob=transport_blob,
ephemeral_private_key=private_key, 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. Process an encrypted message and its files using a single MEK.
- Message transport layer is decrypted using the message client ephemeral key - Message and file transport layers use the same client ephemeral X25519 keypair
- File transport layer is decrypted using the sender long-term public key (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 - One MEK is generated and used to encrypt message + all files
- MEK is wrapped for compliance, sender, and recipient (stored on DM envelope) - MEK is wrapped for compliance, sender, and recipient (stored on DM envelope)
""" """
+2 -3
View File
@@ -1,7 +1,6 @@
fromchat.ru { fromchat.ru {
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 { reverse_proxy frontend:8301 {
lb_policy first header_up X-Real-IP {remote_host}
header_up X-Real-IP {remote_host}
} }
# Security headers # Security headers
+1 -1
View File
@@ -172,7 +172,7 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:
- caddy_data:/root/site/certs - caddy:/root/site/certs
environment: environment:
XDG_DATA_HOME: /root/site/certs XDG_DATA_HOME: /root/site/certs
XDG_CONFIG_HOME: /root/site/certs XDG_CONFIG_HOME: /root/site/certs
+3 -2
View File
@@ -10,11 +10,12 @@ StartLimitBurst=3
Type=simple Type=simple
User=root User=root
Group=root Group=root
ExecStart=/bin/docker compose up ExecStart=/bin/bash -c "COMPOSE_PROFILES=production docker compose up --remove-orphans --force-recreate"
ExecStop=/bin/docker compose down ExecStop=/bin/bash -c "COMPOSE_PROFILES=production docker compose down --remove-orphans"
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
Restart=always Restart=always
RestartSec=10 RestartSec=10
StartLimitBurst=3
# Security settings # Security settings
NoNewPrivileges=true NoNewPrivileges=true
+16 -3
View File
@@ -173,8 +173,21 @@ export async function sendWithFiles(
const transportPublicKey = ub64(transportPublicKeyB64); const transportPublicKey = ub64(transportPublicKeyB64);
// Transport-encrypt message (client-side transport only; server will envelope-encrypt) // One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext || "", transportPublicKeyB64); 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[])) : ""; 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), new Uint8Array(fileData),
transportNonce, transportNonce,
transportPublicKey, transportPublicKey,
keys.privateKey ephemeralKeypair.secretKey
); );
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length); const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
transportEncryptedWithNonce.set(transportNonce); transportEncryptedWithNonce.set(transportNonce);
+16
View File
@@ -168,6 +168,22 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
return pair; 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<void> {
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() { export function restoreKeys() {
currentPublicKey = ub64(localStorage.getItem("publicKey")!); currentPublicKey = ub64(localStorage.getItem("publicKey")!);
currentPrivateKey = ub64(localStorage.getItem("privateKey")!); currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
+5
View File
@@ -94,6 +94,11 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
await api.user.auth.ensureKeysOnLogin(password, data.token); await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) { } catch (e) {
console.error("Key setup failed:", 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 // Ensure WebSocket is connected and authenticated
+5
View File
@@ -122,6 +122,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
await api.user.auth.ensureKeysOnLogin(password, data.token); await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) { } catch (e) {
console.error("Key setup failed:", 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"); navigate("/chat");
+5
View File
@@ -79,6 +79,11 @@ export const useUserStore = create<UserStore>((set) => ({
if (fullResponse.ok) { if (fullResponse.ok) {
const user: User = await fullResponse.json(); const user: User = await fullResponse.json();
api.user.auth.restoreKeys(); 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) { if (user.suspended) {
set({ set({
+96 -37
View File
@@ -312,38 +312,39 @@ docker buildx use "$BUILDER_NAME" > /dev/null 2>&1
# Detect services # Detect services
step "Detecting services" step "Detecting services"
cd "$DEPLOYMENT_DIR" 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) SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
if [ -z "$SERVICES" ]; then if [ -z "$SERVICES" ]; then
error "No services found in docker-compose.yml" error "No services found in docker-compose.yml"
fi 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=() BUILT_IMAGES=()
for SERVICE in $SERVICES; do for SERVICE in $SERVICES; do
HAS_BUILD=$(docker compose -f docker-compose.yml config 2>/dev/null | \ if ! jq -e --arg s "$SERVICE" '(.services[$s].build // false) | type == "object"' <<< "$COMPOSE_JSON" >/dev/null 2>&1; then
grep -A 30 "^[[:space:]]*${SERVICE}:" | \
grep -q "build:" && echo "yes" || echo "no")
if [ "$HAS_BUILD" != "yes" ]; then
continue continue
fi fi
IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest" IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest"
substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..." substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..."
BUILD_OUTPUT=$(docker compose -f docker-compose.yml config 2>/dev/null | \ DOCKERFILE_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.dockerfile // empty' <<< "$COMPOSE_JSON")
grep -A 15 "^[[:space:]]*${SERVICE}:" | \ CONTEXT_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.context // empty' <<< "$COMPOSE_JSON")
grep -A 10 "build:") # 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")
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)
if [ -z "$CONTEXT_REL" ]; then if [ -z "$CONTEXT_REL" ]; then
CONTEXT_REL=".." CONTEXT_REL=".."
@@ -377,12 +378,13 @@ for SERVICE in $SERVICES; do
fi fi
fi fi
if docker buildx build \ BUILDX_ARGS=(buildx build --platform "$PLATFORM" --file "$DOCKERFILE" --tag "$IMAGE_TAG" --load)
--platform "$PLATFORM" \ if [ -n "$BUILD_TARGET" ]; then
--file "$DOCKERFILE" \ BUILDX_ARGS+=(--target "$BUILD_TARGET")
--tag "$IMAGE_TAG" \ fi
--load \ BUILDX_ARGS+=("$BUILD_CONTEXT")
"$BUILD_CONTEXT"; then
if docker "${BUILDX_ARGS[@]}"; then
echo -e " ${GREEN}${NC} Built ${CYAN}$SERVICE${NC}" echo -e " ${GREEN}${NC} Built ${CYAN}$SERVICE${NC}"
BUILT_IMAGES+=("$IMAGE_TAG") BUILT_IMAGES+=("$IMAGE_TAG")
echo "" echo ""
@@ -413,12 +415,7 @@ COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev
IMAGES=() IMAGES=()
for S in $COMPOSE_SERVICES; do for S in $COMPOSE_SERVICES; do
# Try to read explicit image: field from the compose config for this service IMAGE_FROM_COMPOSE=$(jq -r --arg s "$S" '.services[$s].image // empty' <<< "$COMPOSE_JSON")
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)
if [ -n "$IMAGE_FROM_COMPOSE" ]; then if [ -n "$IMAGE_FROM_COMPOSE" ]; then
IMAGES+=("$IMAGE_FROM_COMPOSE") IMAGES+=("$IMAGE_FROM_COMPOSE")
@@ -520,11 +517,11 @@ step "Transferring deployment files"
if [ -n "$SUDO_PASSWORD" ]; then if [ -n "$SUDO_PASSWORD" ]; then
ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1 ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1
set -e set -e
echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $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 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 REMOTE_SUDO_SCRIPT
else 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 fi
# Copy deployment directory excluding gitignored files # Copy deployment directory excluding gitignored files
@@ -561,9 +558,71 @@ else
warning ".env.prod not found in deployment directory" warning ".env.prod not found in deployment directory"
fi 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 # Deploy on server
step "Deploying 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 set -e
REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}" REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}"
@@ -583,7 +642,7 @@ if [ -z "$REMOTE_DEPLOY_PATH" ]; then
exit 1 exit 1
fi fi
mkdir -p "$REMOTE_DEPLOY_PATH/deployment" mkdir -p "$REMOTE_DEPLOY_PATH/deployment" "$REMOTE_DEPLOY_PATH/backend"
cd "$REMOTE_DEPLOY_PATH/deployment" cd "$REMOTE_DEPLOY_PATH/deployment"
if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then
@@ -594,7 +653,7 @@ if systemctl is-active --quiet fromchat; then
sudo_cmd systemctl stop fromchat sudo_cmd systemctl stop fromchat
fi 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 cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service
sudo_cmd systemctl daemon-reload sudo_cmd systemctl daemon-reload