mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Fix production deployment issues
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")!);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
+93
-34
@@ -312,20 +312,28 @@ 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
|
||||||
|
|
||||||
@@ -333,17 +341,10 @@ for SERVICE in $SERVICES; do
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user