238 lines
9.5 KiB
Python
238 lines
9.5 KiB
Python
"""Deploy the official DeepSeek configuration without exposing credentials."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import posixpath
|
|
import shlex
|
|
import stat
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import paramiko
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def load_config(path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for raw in path.read_text(encoding="utf-8-sig").splitlines():
|
|
line = raw.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
key, value = line.split("=", 1)
|
|
values[key.strip()] = value.strip()
|
|
return values
|
|
|
|
|
|
def connect(config: dict[str, str]) -> paramiko.SSHClient:
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
options = {
|
|
"hostname": config["SERVER_HOST"],
|
|
"port": int(config.get("SERVER_PORT", "22")),
|
|
"username": config["SERVER_USER"],
|
|
"timeout": 20,
|
|
"banner_timeout": 20,
|
|
"auth_timeout": 20,
|
|
}
|
|
if config.get("SERVER_KEYFILE"):
|
|
options["key_filename"] = config["SERVER_KEYFILE"]
|
|
else:
|
|
options["password"] = config["SERVER_PASSWORD"]
|
|
client.connect(**options)
|
|
return client
|
|
|
|
|
|
def run(client: paramiko.SSHClient, command: str, timeout: int = 300) -> str:
|
|
_, stdout, stderr = client.exec_command(command, timeout=timeout)
|
|
output = stdout.read().decode("utf-8", errors="replace")
|
|
error = stderr.read().decode("utf-8", errors="replace")
|
|
code = stdout.channel.recv_exit_status()
|
|
if code:
|
|
raise RuntimeError((error or output or f"remote exit {code}").strip())
|
|
return output.strip()
|
|
|
|
|
|
def put_text(sftp: paramiko.SFTPClient, path: str, content: str, mode: int = 0o644) -> None:
|
|
temporary = path + ".tmp"
|
|
with sftp.file(temporary, "w") as handle:
|
|
handle.write(content)
|
|
handle.flush()
|
|
sftp.chmod(temporary, mode)
|
|
sftp.posix_rename(temporary, path)
|
|
|
|
|
|
def update_env(content: str, updates: dict[str, str]) -> str:
|
|
remaining = dict(updates)
|
|
result: list[str] = []
|
|
for line in content.splitlines():
|
|
if "=" in line and not line.lstrip().startswith("#"):
|
|
key = line.split("=", 1)[0].strip()
|
|
if key in remaining:
|
|
result.append(f"{key}={remaining.pop(key)}")
|
|
continue
|
|
result.append(line)
|
|
result.extend(f"{key}={value}" for key, value in remaining.items())
|
|
return "\n".join(result) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
config = load_config(ROOT / "server-deploy.conf")
|
|
secret = config.get("DEEPSEEK_API_KEY", "")
|
|
if not secret.startswith("sk-") or len(secret) < 20:
|
|
raise RuntimeError("DEEPSEEK_API_KEY is missing from server-deploy.conf")
|
|
|
|
base = config["SERVER_DEPLOY_PATH"].rstrip("/")
|
|
port = config.get("PORT", "3200")
|
|
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
client = connect(config)
|
|
sftp = client.open_sftp()
|
|
try:
|
|
current_image = run(client, "docker inspect -f '{{.Config.Image}}' mini-openclaw")
|
|
if not current_image.startswith("mini-openclaw:"):
|
|
raise RuntimeError("Unexpected current image; deployment was not changed")
|
|
new_image = f"mini-openclaw:deepseek-official-{stamp}"
|
|
rollback_container = f"mini-openclaw-rollback-{stamp}"
|
|
|
|
local_files = (
|
|
"backend/services/assistant_model.py",
|
|
"backend/services/project_assistant.py",
|
|
"backend/api/risk_agent_config.py",
|
|
"backend/test_assistant_components.py",
|
|
"backend/test_project_assistant.py",
|
|
"backend/test_risk_agent_config.py",
|
|
"deploy/docker-compose.aliyun.yml",
|
|
"deploy/aliyun.env.example",
|
|
"src/components/xingbang/AgentNav.tsx",
|
|
"src/components/xingbang/BusinessAssistant.tsx",
|
|
"src/app/resetdata/layout.tsx",
|
|
"src/app/resetdata/page.tsx",
|
|
"src/app/resetdata/resetdata.css",
|
|
"scripts/test_resetdata_ui.cjs",
|
|
)
|
|
for relative in local_files:
|
|
remote = posixpath.join(base, relative.replace("\\", "/"))
|
|
try:
|
|
sftp.stat(remote)
|
|
run(client, f"cp -p {shlex.quote(remote)} {shlex.quote(remote + '.pre-deepseek-' + stamp)}")
|
|
except FileNotFoundError:
|
|
run(client, f"mkdir -p {shlex.quote(posixpath.dirname(remote))}")
|
|
sftp.put(str(ROOT / relative), remote)
|
|
|
|
env_path = posixpath.join(base, ".runtime.env")
|
|
with sftp.file(env_path, "r") as handle:
|
|
runtime_env = handle.read().decode("utf-8")
|
|
original_runtime_env = runtime_env
|
|
run(client, f"cp -p {shlex.quote(env_path)} {shlex.quote(env_path + '.pre-deepseek-' + stamp)}")
|
|
runtime_env = update_env(
|
|
runtime_env,
|
|
{
|
|
"DEEPSEEK_API_KEY": secret,
|
|
"DEEPSEEK_BASE_URL": "https://api.deepseek.com",
|
|
"DEEPSEEK_MODEL": "deepseek-chat",
|
|
"XINGBANG_LOCAL_MODEL_KEY": secret,
|
|
"XINGBANG_LOCAL_MODEL_URL": "https://api.deepseek.com",
|
|
"XINGBANG_LOCAL_MODEL_NAME": "deepseek-chat",
|
|
},
|
|
)
|
|
put_text(sftp, env_path, runtime_env, 0o600)
|
|
|
|
probe = r'''
|
|
import os
|
|
import sys
|
|
import httpx
|
|
response = httpx.post(
|
|
"https://api.deepseek.com/chat/completions",
|
|
headers={"Authorization": "Bearer " + os.environ["DEEPSEEK_API_KEY"]},
|
|
json={"model": "deepseek-chat", "messages": [{"role": "user", "content": "Reply with OK."}], "max_tokens": 2, "temperature": 0},
|
|
timeout=30,
|
|
trust_env=False,
|
|
)
|
|
print("deepseek_official_status=" + str(response.status_code))
|
|
sys.exit(0 if response.status_code == 200 else 1)
|
|
'''
|
|
encoded_probe = probe.encode("utf-8").hex()
|
|
probe_command = (
|
|
f"cd {shlex.quote(base)} && docker run --rm --env-file .runtime.env "
|
|
f"--entrypoint python {shlex.quote(current_image)} -c "
|
|
+ shlex.quote("exec(bytes.fromhex(" + repr(encoded_probe) + ").decode('utf-8'))")
|
|
)
|
|
print(run(client, probe_command, timeout=90))
|
|
|
|
dockerfile = (
|
|
"FROM node:18-bookworm-slim AS frontend-builder\n"
|
|
"WORKDIR /app/frontend\n"
|
|
"COPY package*.json ./\n"
|
|
"RUN npm ci\n"
|
|
"COPY src ./src\n"
|
|
"COPY next-env.d.ts next.config.mjs postcss.config.mjs tailwind.config.ts tsconfig.json ./\n"
|
|
"RUN mkdir -p public && npm run build\n"
|
|
f"FROM {current_image}\n"
|
|
"WORKDIR /app\n"
|
|
"COPY backend/services/assistant_model.py /app/backend/services/assistant_model.py\n"
|
|
"COPY backend/services/project_assistant.py /app/backend/services/project_assistant.py\n"
|
|
"COPY backend/api/risk_agent_config.py /app/backend/api/risk_agent_config.py\n"
|
|
"RUN rm -rf /app/frontend/.next /app/frontend/public\n"
|
|
"COPY --from=frontend-builder /app/frontend/.next /app/frontend/.next\n"
|
|
"COPY --from=frontend-builder /app/frontend/public /app/frontend/public\n"
|
|
)
|
|
dockerfile_path = posixpath.join(base, "Dockerfile.deepseek-official")
|
|
put_text(sftp, dockerfile_path, dockerfile)
|
|
print(run(client, f"cd {shlex.quote(base)} && docker build -f Dockerfile.deepseek-official -t {shlex.quote(new_image)} .", timeout=600))
|
|
|
|
state = posixpath.join(base, "state")
|
|
mounts = (
|
|
("workspace", "/app/backend/workspace"),
|
|
("config.json", "/app/backend/config.json"),
|
|
("tokenizer-cache", "/tmp/data-gym-cache"),
|
|
("xingbang_data", "/app/backend/xingbang_data"),
|
|
("logs", "/app/backend/logs"),
|
|
("sessions", "/app/backend/sessions"),
|
|
("raw_logs", "/app/backend/raw_logs"),
|
|
)
|
|
mount_args = " ".join(
|
|
"-v " + shlex.quote(posixpath.join(state, source) + ":" + target)
|
|
for source, target in mounts
|
|
)
|
|
launch = (
|
|
f"cd {shlex.quote(base)} && "
|
|
"docker stop mini-openclaw >/dev/null && "
|
|
f"docker rename mini-openclaw {shlex.quote(rollback_container)} && "
|
|
f"docker run -d --name mini-openclaw --restart unless-stopped --env-file .runtime.env "
|
|
f"-p {shlex.quote(port)}:3000 {mount_args} {shlex.quote(new_image)}"
|
|
)
|
|
run(client, launch, timeout=120)
|
|
|
|
healthy = False
|
|
last = "unknown"
|
|
for _ in range(45):
|
|
last = run(client, "docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' mini-openclaw")
|
|
if last == "healthy":
|
|
healthy = True
|
|
break
|
|
if last in {"unhealthy", "exited", "dead"}:
|
|
break
|
|
time.sleep(4)
|
|
if not healthy:
|
|
run(
|
|
client,
|
|
"docker rm -f mini-openclaw >/dev/null 2>&1 || true; "
|
|
f"docker rename {shlex.quote(rollback_container)} mini-openclaw; "
|
|
"docker start mini-openclaw >/dev/null",
|
|
)
|
|
put_text(sftp, env_path, original_runtime_env, 0o600)
|
|
raise RuntimeError(f"New container health is {last}; previous container was restored")
|
|
|
|
put_text(sftp, posixpath.join(base, ".deployed-version"), new_image + "\n")
|
|
print(json.dumps({"status": "deployed", "image": new_image, "health": "healthy", "rollback_container": rollback_container}))
|
|
return 0
|
|
finally:
|
|
sftp.close()
|
|
client.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|