chore: initialize server repository
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
**
|
||||||
|
!Dockerfile
|
||||||
|
!requirements.txt
|
||||||
|
!main.py
|
||||||
|
!db.py
|
||||||
|
!minio_tool.py
|
||||||
|
!tables.sql
|
||||||
|
!admin.html
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
# Operating systems and editors
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Private requirement / handover documents
|
||||||
|
*.doc
|
||||||
|
*.docx
|
||||||
|
*交付说明*.md
|
||||||
|
|
||||||
|
# Python environments and caches
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Local configuration and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
admin_token.sha256
|
||||||
|
keys/*private*.pem
|
||||||
|
keys/*private*.key
|
||||||
|
|
||||||
|
# Runtime databases, uploads, object storage and generated packages
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
local_uploads/
|
||||||
|
upload_spool/
|
||||||
|
crash_storage/
|
||||||
|
minio_data/
|
||||||
|
runtime/
|
||||||
|
dist/
|
||||||
|
minio
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Docker/local generated files
|
||||||
|
.dockerignore.local
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libarchive-tools \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY server/requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./
|
||||||
|
COPY --chown=updateapp:updateapp server/admin.html ./admin.html
|
||||||
|
|
||||||
|
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
||||||
|
&& chown -R updateapp:updateapp /app /data \
|
||||||
|
&& chmod 644 /app/admin.html
|
||||||
|
|
||||||
|
USER updateapp
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
+2428
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def configured_path(name: str, default: str) -> Path:
|
||||||
|
path = Path(os.getenv(name, default)).expanduser()
|
||||||
|
return path if path.is_absolute() else BASE_DIR / path
|
||||||
|
|
||||||
|
|
||||||
|
DB_FILE = configured_path("DB_FILE", "mini.db")
|
||||||
|
SQL_FILE = configured_path("SQL_FILE", "tables.sql")
|
||||||
|
|
||||||
|
# 初始化数据库
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cur = conn.cursor()
|
||||||
|
sql = Path(SQL_FILE).read_text("utf-8")
|
||||||
|
cur.executescript(sql)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"数据库 {DB_FILE} 创建完成")
|
||||||
|
|
||||||
|
# 获取连接
|
||||||
|
def get_conn():
|
||||||
|
conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- ./minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
||||||
|
mc mb --ignore-existing "update-store/$${MINIO_BUCKET}"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
SERVER_HOST: 0.0.0.0
|
||||||
|
SERVER_PORT: 8000
|
||||||
|
ENV_FILE_PATH: /app/.env
|
||||||
|
DB_FILE: /data/mini.db
|
||||||
|
SQL_FILE: /app/tables.sql
|
||||||
|
ADMIN_HTML_PATH: /app/admin.html
|
||||||
|
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||||
|
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||||
|
MINIO_DATA_DIR: /minio_data
|
||||||
|
CRASH_STORAGE_ROOT: /data/crash_storage
|
||||||
|
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required}
|
||||||
|
MINIO_AUTO_START: "false"
|
||||||
|
MINIO_SECURE: "false"
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./.env:/app/.env
|
||||||
|
- ./runtime:/data
|
||||||
|
- ./minio_data:/minio_data:ro
|
||||||
|
- ./keys:/run/secrets/update-keys:ro
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- ./minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
||||||
|
mc mb --ignore-existing "update-store/$${MINIO_BUCKET}"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
SERVER_HOST: 0.0.0.0
|
||||||
|
SERVER_PORT: 8000
|
||||||
|
ENV_FILE_PATH: /app/.env
|
||||||
|
DB_FILE: /data/mini.db
|
||||||
|
SQL_FILE: /app/tables.sql
|
||||||
|
ADMIN_HTML_PATH: /app/admin.html
|
||||||
|
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||||
|
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||||
|
MINIO_DATA_DIR: /minio_data
|
||||||
|
CRASH_STORAGE_ROOT: /data/crash_storage
|
||||||
|
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required}
|
||||||
|
MINIO_AUTO_START: "false"
|
||||||
|
MINIO_SECURE: "false"
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./.env:/app/.env
|
||||||
|
- ./runtime:/data
|
||||||
|
- ./minio_data:/minio_data:ro
|
||||||
|
- ./keys:/run/secrets/update-keys:ro
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
|
||||||
|
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
|
||||||
|
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
|
||||||
|
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
|
||||||
|
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
|
||||||
|
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
|
||||||
|
TwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
from minio import Minio
|
||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import io
|
||||||
|
import socket
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib3
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib3.util import Retry, Timeout
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET")
|
||||||
|
SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60)
|
||||||
|
LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")).expanduser()
|
||||||
|
if not LOCAL_UPLOAD_ROOT.is_absolute():
|
||||||
|
LOCAL_UPLOAD_ROOT = BASE_DIR / LOCAL_UPLOAD_ROOT
|
||||||
|
LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE")
|
||||||
|
MINIO_SECURE = env_bool("MINIO_SECURE")
|
||||||
|
MINIO_CONNECT_TIMEOUT_SEC = float(os.getenv("MINIO_CONNECT_TIMEOUT_SEC", "2"))
|
||||||
|
MINIO_READ_TIMEOUT_SEC = float(os.getenv("MINIO_READ_TIMEOUT_SEC", "5"))
|
||||||
|
MINIO_RETRY_TOTAL = int(os.getenv("MINIO_RETRY_TOTAL", "1"))
|
||||||
|
LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
http_client = urllib3.PoolManager(
|
||||||
|
timeout=Timeout(connect=MINIO_CONNECT_TIMEOUT_SEC, read=MINIO_READ_TIMEOUT_SEC),
|
||||||
|
retries=Retry(total=MINIO_RETRY_TOTAL, connect=MINIO_RETRY_TOTAL,
|
||||||
|
read=MINIO_RETRY_TOTAL, status=MINIO_RETRY_TOTAL, backoff_factor=0.2),
|
||||||
|
)
|
||||||
|
|
||||||
|
mc = Minio(
|
||||||
|
MINIO_ENDPOINT,
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
secure=MINIO_SECURE,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
public_endpoint_raw = os.getenv("MINIO_PUBLIC_ENDPOINT", "").strip()
|
||||||
|
if public_endpoint_raw:
|
||||||
|
parsed_public_endpoint = urlparse(
|
||||||
|
public_endpoint_raw if "://" in public_endpoint_raw else f"http://{public_endpoint_raw}"
|
||||||
|
)
|
||||||
|
public_mc = Minio(
|
||||||
|
parsed_public_endpoint.netloc,
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
secure=parsed_public_endpoint.scheme == "https",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
public_mc = mc
|
||||||
|
|
||||||
|
|
||||||
|
def get_url(file_path: str) -> str:
|
||||||
|
bucket = MINIO_BUCKET
|
||||||
|
full_object_path = file_path
|
||||||
|
try:
|
||||||
|
mc.stat_object(bucket, full_object_path)
|
||||||
|
url = public_mc.presigned_get_object(
|
||||||
|
bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN)
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
except Exception as err:
|
||||||
|
print(f"generate url error: {err}")
|
||||||
|
local_path = LOCAL_UPLOAD_ROOT / file_path
|
||||||
|
if local_path.exists():
|
||||||
|
if LOCAL_FILE_URL_BASE:
|
||||||
|
return LOCAL_FILE_URL_BASE.rstrip('/') + '/' + file_path
|
||||||
|
host = os.getenv('SERVER_HOST') or '127.0.0.1'
|
||||||
|
if host == '0.0.0.0':
|
||||||
|
try:
|
||||||
|
host = socket.gethostbyname(socket.gethostname())
|
||||||
|
except Exception:
|
||||||
|
host = '127.0.0.1'
|
||||||
|
port = os.getenv('SERVER_PORT') or '8000'
|
||||||
|
return f"http://{host}:{port}/static/{file_path}"
|
||||||
|
scheme = "https" if MINIO_SECURE else "http"
|
||||||
|
return f"{scheme}://{MINIO_ENDPOINT}/{bucket}/{full_object_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def put_file(object_path: str, data: bytes, size: int):
|
||||||
|
bucket = MINIO_BUCKET
|
||||||
|
try:
|
||||||
|
bio = io.BytesIO(data)
|
||||||
|
mc.put_object(bucket, object_path, bio, length=size)
|
||||||
|
return {'storage': 'minio', 'path': object_path}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"minio put_file error: {e}")
|
||||||
|
local_path = LOCAL_UPLOAD_ROOT / object_path
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
print(f"local fallback stored {local_path}")
|
||||||
|
return {'storage': 'local', 'path': str(local_path)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def put_file_stream(object_path: str, source, size: int):
|
||||||
|
bucket = MINIO_BUCKET
|
||||||
|
try:
|
||||||
|
source.seek(0)
|
||||||
|
mc.put_object(bucket, object_path, source, length=size)
|
||||||
|
return {'storage': 'minio', 'path': object_path}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"minio put_file_stream error: {e}")
|
||||||
|
local_path = LOCAL_UPLOAD_ROOT / object_path
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source.seek(0)
|
||||||
|
with open(local_path, 'wb') as target:
|
||||||
|
shutil.copyfileobj(source, target, length=1024 * 1024)
|
||||||
|
print(f"local fallback stored {local_path}")
|
||||||
|
return {'storage': 'local', 'path': str(local_path)}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_prefix(prefix: str):
|
||||||
|
bucket = MINIO_BUCKET
|
||||||
|
try:
|
||||||
|
objects = mc.list_objects(bucket, prefix=prefix, recursive=True)
|
||||||
|
for obj in objects:
|
||||||
|
try:
|
||||||
|
mc.remove_object(bucket, obj.object_name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"failed remove object {obj.object_name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"minio remove_prefix error: {e}")
|
||||||
|
local_prefix = LOCAL_UPLOAD_ROOT / prefix
|
||||||
|
if local_prefix.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(local_prefix)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"local remove_prefix error: {e}")
|
||||||
|
|
||||||
|
def iter_file(object_path: str, chunk_size: int = 1024 * 1024):
|
||||||
|
"""Stream an object without creating a second full local copy."""
|
||||||
|
response = None
|
||||||
|
emitted = 0
|
||||||
|
try:
|
||||||
|
response = mc.get_object(MINIO_BUCKET, object_path)
|
||||||
|
while True:
|
||||||
|
chunk = response.read(chunk_size)
|
||||||
|
if not chunk: break
|
||||||
|
emitted += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
except Exception as err:
|
||||||
|
if emitted:
|
||||||
|
raise RuntimeError(f"MinIO stream interrupted after {emitted} bytes: {object_path}") from err
|
||||||
|
print(f"MinIO stream fallback for {object_path}: {err}")
|
||||||
|
finally:
|
||||||
|
if response is not None:
|
||||||
|
response.close(); response.release_conn()
|
||||||
|
local_path = LOCAL_UPLOAD_ROOT / object_path
|
||||||
|
with open(local_path, "rb") as source:
|
||||||
|
while True:
|
||||||
|
chunk = source.read(chunk_size)
|
||||||
|
if not chunk: break
|
||||||
|
yield chunk
|
||||||
Executable
+511
@@ -0,0 +1,511 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="0.1.0"
|
||||||
|
OUTPUT_DIR=""
|
||||||
|
SKIP_BUILD=0
|
||||||
|
SKIP_PULL=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: ./package-offline-server.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Image/package version, default: 0.1.0
|
||||||
|
--output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage
|
||||||
|
--skip-build Do not build simcae-update-server image
|
||||||
|
--skip-pull Do not pull MinIO images
|
||||||
|
-h, --help Show this help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:?--version requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--output-dir)
|
||||||
|
OUTPUT_DIR="${2:?--output-dir requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-build)
|
||||||
|
SKIP_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-pull)
|
||||||
|
SKIP_PULL=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local message="$2"
|
||||||
|
if [[ ! -e "$path" ]]; then
|
||||||
|
echo "$message" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local name="$1"
|
||||||
|
if ! command -v "$name" >/dev/null 2>&1; then
|
||||||
|
echo "Required command is missing: $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd docker
|
||||||
|
require_cmd tar
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
if [[ -z "$OUTPUT_DIR" ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_NAME="simcae-update-server:$VERSION"
|
||||||
|
MINIO_IMAGE="minio/minio:RELEASE.2025-04-22T22-12-26Z"
|
||||||
|
MC_IMAGE="minio/mc:RELEASE.2025-04-16T18-13-26Z"
|
||||||
|
PACKAGE_DIR="$OUTPUT_DIR"
|
||||||
|
IMAGES_DIR="$PACKAGE_DIR/images"
|
||||||
|
KEYS_DIR="$PACKAGE_DIR/keys"
|
||||||
|
TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar"
|
||||||
|
ARCHIVE_FILE="$PACKAGE_DIR.tar.gz"
|
||||||
|
|
||||||
|
require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing."
|
||||||
|
require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing."
|
||||||
|
require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing."
|
||||||
|
require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing."
|
||||||
|
require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing."
|
||||||
|
|
||||||
|
if [[ "$SKIP_BUILD" -eq 0 ]]; then
|
||||||
|
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_PULL" -eq 0 ]]; then
|
||||||
|
docker pull "$MINIO_IMAGE"
|
||||||
|
docker pull "$MC_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$PACKAGE_DIR"
|
||||||
|
mkdir -p "$IMAGES_DIR" "$KEYS_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
|
||||||
|
cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example"
|
||||||
|
cp "$SCRIPT_DIR/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem"
|
||||||
|
cp "$SCRIPT_DIR/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem"
|
||||||
|
|
||||||
|
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
|
||||||
|
# SimCAE 服务端 Docker 部署包说明
|
||||||
|
|
||||||
|
这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。
|
||||||
|
|
||||||
|
如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。
|
||||||
|
|
||||||
|
## 一、这个包里面有什么
|
||||||
|
|
||||||
|
解压后目录结构如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SimCAEServerDockerPackage/
|
||||||
|
README.md 当前说明文档
|
||||||
|
docker-compose.yml 容器编排文件
|
||||||
|
.env.example 环境变量模板
|
||||||
|
load-images.sh 导入 Docker 镜像并创建 .env 的脚本
|
||||||
|
images/
|
||||||
|
simcae-server-all-images___VERSION__.tar Docker 镜像包,包含 api、minio、minio-init
|
||||||
|
keys/
|
||||||
|
manifest_private_key.pem 服务端 Manifest 签名私钥
|
||||||
|
manifest_public_key.pem 与客户端配套的验签公钥
|
||||||
|
```
|
||||||
|
|
||||||
|
这些文件的关系可以这样理解:
|
||||||
|
|
||||||
|
```text
|
||||||
|
images/*.tar 程序镜像包,相当于安装材料
|
||||||
|
docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载
|
||||||
|
.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成
|
||||||
|
keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签
|
||||||
|
runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等
|
||||||
|
minio_data/ 启动后自动生成,保存升级包文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、最小部署流程
|
||||||
|
|
||||||
|
先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf SimCAEServerDockerPackage.tar.gz
|
||||||
|
cd SimCAEServerDockerPackage
|
||||||
|
pwd
|
||||||
|
ls
|
||||||
|
```
|
||||||
|
|
||||||
|
你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
load-images.sh
|
||||||
|
images/
|
||||||
|
keys/
|
||||||
|
```
|
||||||
|
|
||||||
|
后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。
|
||||||
|
|
||||||
|
继续在 `SimCAEServerDockerPackage` 目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./load-images.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
这个脚本会做两件事:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像
|
||||||
|
2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env
|
||||||
|
3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限
|
||||||
|
```
|
||||||
|
|
||||||
|
然后修改 `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
例如服务器 IP 是 `192.168.229.128`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态和后端日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
后台/API: http://你的服务器IP:8000/
|
||||||
|
MinIO 控制台: http://你的服务器IP:9001/
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务器开启防火墙,至少放行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
8000 后台/API
|
||||||
|
9000 客户端下载升级文件
|
||||||
|
9001 MinIO 控制台,可选
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、.env 配置怎么填
|
||||||
|
|
||||||
|
`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。
|
||||||
|
|
||||||
|
### 必须确认或修改
|
||||||
|
|
||||||
|
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
|
||||||
|
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
|
||||||
|
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 |
|
||||||
|
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
|
||||||
|
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
|
||||||
|
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
|
||||||
|
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
|
||||||
|
|
||||||
|
### 通常不用改
|
||||||
|
|
||||||
|
| 字段 | 作用 | 默认值说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:__VERSION__`。 |
|
||||||
|
| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
|
||||||
|
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
|
||||||
|
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 |
|
||||||
|
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 |
|
||||||
|
| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
|
||||||
|
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
|
||||||
|
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
|
||||||
|
| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 |
|
||||||
|
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
|
||||||
|
| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 |
|
||||||
|
|
||||||
|
### 发布保护参数
|
||||||
|
|
||||||
|
这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。
|
||||||
|
|
||||||
|
| 字段 | 作用 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` |
|
||||||
|
| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` |
|
||||||
|
| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` |
|
||||||
|
| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` |
|
||||||
|
|
||||||
|
如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。
|
||||||
|
|
||||||
|
### 崩溃报告接口参数
|
||||||
|
|
||||||
|
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 |
|
||||||
|
| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 |
|
||||||
|
| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 |
|
||||||
|
| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 |
|
||||||
|
| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 |
|
||||||
|
| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 |
|
||||||
|
| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 |
|
||||||
|
| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 |
|
||||||
|
| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 |
|
||||||
|
|
||||||
|
## 四、客户端配置要同步哪些值
|
||||||
|
|
||||||
|
客户端 `config/app_config.json` 至少要和服务端保持这两个值一致:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_base_url": "http://你的服务器IP:8000",
|
||||||
|
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务端 `.env` 里保持默认:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端就填:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"client_token": "SimCAEClientToken2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
|
||||||
|
|
||||||
|
## 五、发布新版本
|
||||||
|
|
||||||
|
进入后台后,“发布新版本”支持两种方式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe
|
||||||
|
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
||||||
|
```
|
||||||
|
|
||||||
|
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
|
||||||
|
|
||||||
|
## 六、常用命令
|
||||||
|
|
||||||
|
确认你仍然在 `SimCAEServerDockerPackage` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwd
|
||||||
|
```
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
查看后端日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
查看所有容器日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
重启后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart api
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。
|
||||||
|
|
||||||
|
## 七、数据、备份和迁移
|
||||||
|
|
||||||
|
运行后会生成这些目录或文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env 当前服务器实际配置
|
||||||
|
runtime/ 服务端数据库、上传临时目录、崩溃报告等
|
||||||
|
minio_data/ MinIO 对象数据,也就是上传后的升级文件
|
||||||
|
keys/ Manifest 签名密钥
|
||||||
|
```
|
||||||
|
|
||||||
|
备份或迁移正式环境时,重点备份:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env
|
||||||
|
runtime/
|
||||||
|
minio_data/
|
||||||
|
keys/
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。
|
||||||
|
|
||||||
|
## 八、常见问题
|
||||||
|
|
||||||
|
### 启动后看不到后端日志
|
||||||
|
|
||||||
|
`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 容器反复重启,提示 Permission denied: '/data/uploads'
|
||||||
|
|
||||||
|
这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage
|
||||||
|
sudo chown -R $(id -u):$(id -g) runtime
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网页能打开,但客户端提示令牌校验失败
|
||||||
|
|
||||||
|
检查客户端 `config/app_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"client_token": "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
必须和服务端 `.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLIENT_API_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
完全一致。
|
||||||
|
|
||||||
|
### 客户端能连 API,但下载升级文件失败
|
||||||
|
|
||||||
|
检查 `.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
|
||||||
|
|
||||||
|
## 九、安全提醒
|
||||||
|
|
||||||
|
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
|
||||||
|
|
||||||
|
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
|
||||||
|
EOF_README
|
||||||
|
|
||||||
|
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
version = sys.argv[2]
|
||||||
|
text = path.read_text(encoding='utf-8').replace('__VERSION__', version)
|
||||||
|
path.write_text(text, encoding='utf-8')
|
||||||
|
PYREADME
|
||||||
|
|
||||||
|
cat > "$PACKAGE_DIR/load-images.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker load -i ./images/simcae-server-all-images_$VERSION.tar
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
sed -i "s/^APP_UID=.*/APP_UID=\$(id -u)/" .env
|
||||||
|
sed -i "s/^APP_GID=.*/APP_GID=\$(id -g)/" .env
|
||||||
|
echo "Created .env from .env.example. Edit .env before starting services."
|
||||||
|
else
|
||||||
|
echo ".env already exists. Keeping existing file."
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_UID_VALUE="\$(grep -E '^APP_UID=' .env | tail -n 1 | cut -d= -f2-)"
|
||||||
|
APP_GID_VALUE="\$(grep -E '^APP_GID=' .env | tail -n 1 | cut -d= -f2-)"
|
||||||
|
APP_UID_VALUE="\${APP_UID_VALUE:-\$(id -u)}"
|
||||||
|
APP_GID_VALUE="\${APP_GID_VALUE:-\$(id -g)}"
|
||||||
|
|
||||||
|
mkdir -p ./runtime/uploads ./runtime/upload_spool ./runtime/crash_storage ./minio_data
|
||||||
|
|
||||||
|
if [[ -w ./runtime ]]; then
|
||||||
|
chown -R "\$APP_UID_VALUE:\$APP_GID_VALUE" ./runtime 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "Warning: ./runtime is not writable by the current user."
|
||||||
|
echo "Run: sudo chown -R \$APP_UID_VALUE:\$APP_GID_VALUE ./runtime"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Next: edit .env, then run: docker compose up -d"
|
||||||
|
EOF
|
||||||
|
chmod +x "$PACKAGE_DIR/load-images.sh"
|
||||||
|
|
||||||
|
python3 - "$PACKAGE_DIR/.env.example" "$IMAGE_NAME" <<'PYENV'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
image = sys.argv[2]
|
||||||
|
text = path.read_text(encoding='utf-8')
|
||||||
|
text = text.replace('SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0', f'SIMCAE_UPDATE_SERVER_IMAGE={image}')
|
||||||
|
path.write_text(text, encoding='utf-8')
|
||||||
|
PYENV
|
||||||
|
|
||||||
|
docker save -o "$TAR_FILE" "$IMAGE_NAME" "$MINIO_IMAGE" "$MC_IMAGE"
|
||||||
|
|
||||||
|
rm -f "$ARCHIVE_FILE"
|
||||||
|
tar -czf "$ARCHIVE_FILE" -C "$(dirname "$PACKAGE_DIR")" "$(basename "$PACKAGE_DIR")"
|
||||||
|
|
||||||
|
echo "Offline package directory: $PACKAGE_DIR"
|
||||||
|
echo "Offline package archive: $ARCHIVE_FILE"
|
||||||
|
echo "Images tar: $TAR_FILE"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
cryptography==49.0.0
|
||||||
|
fastapi==0.138.0
|
||||||
|
minio==7.2.20
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.32
|
||||||
|
uvicorn==0.49.0
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
-- 1.软件应用表:管理所有接入自动更新系统的软件
|
||||||
|
CREATE TABLE IF NOT EXISTS apps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键ID
|
||||||
|
app_id TEXT NOT NULL UNIQUE, -- 软件唯一标识(客户端用来区分不同软件)
|
||||||
|
app_name TEXT NOT NULL -- 软件展示名称
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2.软件版本表:存储每个软件各个渠道下的所有版本号
|
||||||
|
CREATE TABLE IF NOT EXISTS versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 版本自增主键version_id,关联文件表
|
||||||
|
app_id TEXT NOT NULL, -- 关联apps表的软件唯一标识
|
||||||
|
channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版
|
||||||
|
version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2
|
||||||
|
latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版
|
||||||
|
client_protocol INTEGER NOT NULL DEFAULT 1, -- 该版本客户端支持的更新/策略协议版本
|
||||||
|
create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间
|
||||||
|
UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3.版本关联文件表:记录每个版本需要更新的全部文件信息(exe、dll、资源文件)
|
||||||
|
CREATE TABLE IF NOT EXISTS version_files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 文件记录自增ID
|
||||||
|
version_id INTEGER NOT NULL, -- 关联versions表的版本主键id
|
||||||
|
path TEXT NOT NULL, -- 文件在MinIO存储桶内的完整路径
|
||||||
|
sha256 TEXT NOT NULL, -- 文件哈希值,客户端下载后做完整性/防篡改校验
|
||||||
|
size INTEGER -- 文件字节大小,用于计算下载进度、校验磁盘空间
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4.升级日志记录表:存储所有客户端上报的升级结果,用于后台统计排查问题
|
||||||
|
CREATE TABLE IF NOT EXISTS upgrade_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 日志自增主键
|
||||||
|
device_id TEXT, -- 用户设备唯一标识,区分不同电脑客户端
|
||||||
|
from_ver TEXT, -- 升级前本地旧版本号
|
||||||
|
to_ver TEXT, -- 升级目标新版本号
|
||||||
|
result TEXT, -- 升级结果:success成功 / fail失败
|
||||||
|
create_time TEXT -- 升级上报时间
|
||||||
|
);
|
||||||
|
|
||||||
|
--5.日志数据表
|
||||||
|
CREATE TABLE IF NOT EXISTS update_report (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
from_version TEXT NOT NULL,
|
||||||
|
to_version TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. 每个应用/渠道的运行与升级策略;每次保存必须递增 policy_seq
|
||||||
|
CREATE TABLE IF NOT EXISTS version_policies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'stable',
|
||||||
|
policy_seq INTEGER NOT NULL DEFAULT 1,
|
||||||
|
force_update INTEGER NOT NULL DEFAULT 0,
|
||||||
|
allow_rollback INTEGER NOT NULL DEFAULT 0,
|
||||||
|
offline_allowed INTEGER NOT NULL DEFAULT 1,
|
||||||
|
valid_until TEXT NOT NULL,
|
||||||
|
min_supported_version TEXT NOT NULL DEFAULT '',
|
||||||
|
disabled_versions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(app_id, channel)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 7. RSA 签发的客户端设备身份
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL UNIQUE, app_id TEXT NOT NULL,
|
||||||
|
installation_id TEXT NOT NULL, machine_hash TEXT NOT NULL DEFAULT '', credential_seq INTEGER NOT NULL DEFAULT 1,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), last_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(app_id, installation_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 8. 软件授权及其设备占用关系
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable',
|
||||||
|
max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS license_devices (
|
||||||
|
license_id TEXT NOT NULL, device_id TEXT NOT NULL UNIQUE, bound_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY(license_id,device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 9. 每个应用独立维护的动态发布渠道
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, channel_code TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(app_id, channel_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 10. 文件下载授权与客户端完成结果
|
||||||
|
CREATE TABLE IF NOT EXISTS download_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, license_id TEXT NOT NULL,
|
||||||
|
app_id TEXT NOT NULL, version TEXT NOT NULL, channel_code TEXT NOT NULL, file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL DEFAULT 0, result TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_logs_created ON download_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,created_at);
|
||||||
|
|
||||||
|
-- 11. 管理后台写操作审计
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL,
|
||||||
|
status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at);
|
||||||
|
|
||||||
|
|
||||||
|
-- 12. SimCAE 崩溃报告原始数据索引
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
report_id TEXT NOT NULL UNIQUE,
|
||||||
|
client_report_id TEXT NOT NULL UNIQUE,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'stored',
|
||||||
|
symbolication_status TEXT NOT NULL DEFAULT 'not_started',
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
app_version TEXT NOT NULL,
|
||||||
|
git_commit TEXT NOT NULL,
|
||||||
|
build_type TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
exception_code TEXT NOT NULL DEFAULT '',
|
||||||
|
crash_time_utc TEXT NOT NULL,
|
||||||
|
received_at_utc TEXT NOT NULL,
|
||||||
|
remote_address TEXT NOT NULL DEFAULT '',
|
||||||
|
content_length INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata_sha256 TEXT NOT NULL,
|
||||||
|
metadata_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
minidump_sha256 TEXT NOT NULL,
|
||||||
|
minidump_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
attachments_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
attachments_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL,
|
||||||
|
server_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_reports_build ON crash_reports(product,app_version,git_commit);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_reports_exception ON crash_reports(exception_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_reports_received ON crash_reports(received_at_utc);
|
||||||
|
|
||||||
|
-- 13. SimCAE 符号包上传索引
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_symbol_uploads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol_upload_id TEXT NOT NULL UNIQUE,
|
||||||
|
identity_hash TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'stored',
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
app_version TEXT NOT NULL,
|
||||||
|
git_commit TEXT NOT NULL,
|
||||||
|
build_type TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
toolchain TEXT NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
received_at_utc TEXT NOT NULL,
|
||||||
|
metadata_sha256 TEXT NOT NULL,
|
||||||
|
metadata_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
symbols_sha256 TEXT NOT NULL,
|
||||||
|
symbols_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_symbols_build ON crash_symbol_uploads(product,app_version,git_commit,build_type,platform);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_symbols_received ON crash_symbol_uploads(received_at_utc);
|
||||||
|
|
||||||
|
-- 14. 崩溃原始文件访问审计
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_file_access_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
report_id TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
actor_hash TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_file_access_report ON crash_file_access_logs(report_id,created_at);
|
||||||
Reference in New Issue
Block a user