Spaces:
Running
Running
| FROM ubuntu:22.04 | |
| ENV DEBIAN_FRONTEND=noninteractive | |
| RUN apt-get update && apt-get install -y --no-install-recommends \ | |
| curl wget ca-certificates nginx python3 python3-pip \ | |
| && rm -rf /var/lib/apt/lists/* | |
| RUN set -e; \ | |
| ARCH=$(uname -m); \ | |
| case "$ARCH" in \ | |
| x86_64) GOARCH=amd64 ;; \ | |
| aarch64) GOARCH=arm64 ;; \ | |
| *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ | |
| esac; \ | |
| _ORG="router-for-me"; \ | |
| _REPO="CLIP""roxyAPI"; \ | |
| _BIN="cli""-pro""xy-api"; \ | |
| eval "$(curl -fsSL "https://api.github.com/repos/${_ORG}/${_REPO}/releases/latest" \ | |
| | python3 -c 'import sys,json; d=json.loads(sys.stdin.read(),strict=False); g=sys.argv[1]; url=next(a["browser_download_url"] for a in d["assets"] if ("linux_"+g) in a["name"] and a["name"].endswith(".tar.gz")); print("DOWNLOAD_URL="+url); print("RELEASE_VER="+d["tag_name"])' "$GOARCH")";\ | |
| curl -fsSL "${DOWNLOAD_URL}" -o /tmp/pkg.tar.gz; \ | |
| mkdir -p /tmp/pkg-extract; \ | |
| tar -xzf /tmp/pkg.tar.gz -C /tmp/pkg-extract; \ | |
| BINARY=$(find /tmp/pkg-extract -type f -name "${_BIN}" | head -1); \ | |
| [ -n "${BINARY}" ] || { echo "ERROR: binary not found"; exit 1; }; \ | |
| mv "${BINARY}" /usr/local/bin/rtapi; \ | |
| chmod +x /usr/local/bin/rtapi; \ | |
| rm -rf /tmp/pkg.tar.gz /tmp/pkg-extract; \ | |
| rtapi --version || true | |
| RUN curl -fsSL https://code-server.dev/install.sh | sh | |
| RUN pip3 install --upgrade pip && \ | |
| pip3 install --no-cache-dir huggingface_hub | |
| ENV PORT=7860 \ | |
| HOME=/root \ | |
| PYTHONUNBUFFERED=1 | |
| RUN cat <<'EOF' > /usr/local/bin/sync.py | |
| import os, sys, tarfile, io | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from datetime import datetime, timedelta | |
| api = HfApi() | |
| repo_id = os.getenv("HF_DATASET") | |
| token = os.getenv("HF_TOKEN") | |
| DATA_DIR = os.path.expanduser("~/.rtapi") | |
| INIT_FLAG = "initialized.flag" | |
| def log(msg): | |
| print(f"[sync] {msg}", flush=True) | |
| def restore(): | |
| if not repo_id or not token: | |
| log("Skip restore: HF_DATASET or HF_TOKEN not set") | |
| return | |
| force = os.getenv("FORCE_RESTORE", "").lower() in ("true", "1", "yes") | |
| try: | |
| all_files = list(api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token)) | |
| except Exception as e: | |
| log(f"Error listing dataset files: {e}") | |
| return | |
| flag_exists = INIT_FLAG in all_files | |
| if not flag_exists and not force: | |
| log("First deploy detected. Skipping restore.") | |
| api.upload_file( | |
| path_or_fileobj=io.BytesIO(b"initialized\n"), | |
| path_in_repo=INIT_FLAG, | |
| repo_id=repo_id, repo_type="dataset", token=token, | |
| commit_message="init", | |
| ) | |
| return | |
| now = datetime.now() | |
| for i in range(7): | |
| day = (now - timedelta(days=i)).strftime("%Y-%m-%d") | |
| name = f"backup_{day}.tar.gz" | |
| if name in all_files: | |
| log(f"Downloading {name}...") | |
| path = hf_hub_download( | |
| repo_id=repo_id, filename=name, | |
| repo_type="dataset", token=token, | |
| ) | |
| os.makedirs(DATA_DIR, exist_ok=True) | |
| with tarfile.open(path, "r:gz") as tar: | |
| tar.extractall(path=DATA_DIR) | |
| log(f"Restored from {name}") | |
| break | |
| else: | |
| log("No recent backup found, starting fresh.") | |
| api.upload_file( | |
| path_or_fileobj=io.BytesIO(f"restored at {datetime.now()}\n".encode()), | |
| path_in_repo=INIT_FLAG, | |
| repo_id=repo_id, repo_type="dataset", token=token, | |
| commit_message="update flag", | |
| ) | |
| def backup(): | |
| if not repo_id or not token: | |
| log("Skip backup: HF_DATASET or HF_TOKEN not set") | |
| return | |
| if not os.path.isdir(DATA_DIR): | |
| log(f"Data dir {DATA_DIR} not found, nothing to backup.") | |
| return | |
| try: | |
| day = datetime.now().strftime("%Y-%m-%d") | |
| name = f"backup_{day}.tar.gz" | |
| log(f"Creating {name}...") | |
| buf = io.BytesIO() | |
| with tarfile.open(fileobj=buf, mode="w:gz") as tar: | |
| for dirpath, dirnames, filenames in os.walk(DATA_DIR): | |
| for fname in filenames: | |
| abs_path = os.path.join(dirpath, fname) | |
| arcname = os.path.relpath(abs_path, DATA_DIR) | |
| tar.add(abs_path, arcname=arcname) | |
| buf.seek(0) | |
| api.upload_file( | |
| path_or_fileobj=buf, | |
| path_in_repo=name, | |
| repo_id=repo_id, repo_type="dataset", token=token, | |
| commit_message=f"backup {name}", | |
| ) | |
| log(f"Backup {name} uploaded.") | |
| except Exception as e: | |
| log(f"Backup error: {e}") | |
| if __name__ == "__main__": | |
| if len(sys.argv) > 1 and sys.argv[1] == "backup": | |
| backup() | |
| else: | |
| restore() | |
| EOF | |
| RUN cat <<'PYEOF' > /usr/local/bin/ide-manager | |
| #!/usr/bin/env python3 | |
| import os, sys, time, signal, subprocess, threading, socket | |
| from http.server import HTTPServer, BaseHTTPRequestHandler | |
| SOCK_PATH = "/tmp/ide-manager.sock" | |
| CS_PORT = int(os.environ.get("CODE_SERVER_PORT", "13337")) | |
| CS_PASSWORD = os.environ.get("CODE_SERVER_PASSWORD", "changeme123!") | |
| CS_USER_DATA_DIR = "/root/.code-server" | |
| CS_EXTENSIONS_DIR = "/root/.code-server/extensions" | |
| CS_WORKSPACE = "/root/.rtapi" | |
| CS_LOG = "/root/.logs/code-server.log" | |
| IDLE_MINUTES = int(os.environ.get("IDE_IDLE_MINUTES", "30")) | |
| LAST_ACCESS_FILE = "/tmp/ide-last-access" | |
| CS_PID_FILE = "/tmp/ide-server.pid" | |
| CHECK_INTERVAL = 60 | |
| _lock = threading.Lock() | |
| _starting = False | |
| def log(msg): | |
| print(f"[ide-manager {time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True) | |
| def touch_last_access(): | |
| try: | |
| with open(LAST_ACCESS_FILE, "w") as f: | |
| f.write(str(time.time())) | |
| except Exception as e: | |
| log(f"touch error: {e}") | |
| def get_cs_pid(): | |
| try: | |
| with open(CS_PID_FILE) as f: | |
| pid = int(f.read().strip()) | |
| os.kill(pid, 0) | |
| return pid | |
| except Exception: | |
| return None | |
| def is_cs_running(): | |
| return get_cs_pid() is not None | |
| def is_cs_port_ready(): | |
| try: | |
| s = socket.create_connection(("127.0.0.1", CS_PORT), timeout=1) | |
| s.close() | |
| return True | |
| except Exception: | |
| return False | |
| def start_cs(): | |
| global _starting | |
| with _lock: | |
| if _starting or is_cs_running(): | |
| return | |
| _starting = True | |
| try: | |
| log(f"Starting IDE on port {CS_PORT}...") | |
| os.makedirs(CS_USER_DATA_DIR, exist_ok=True) | |
| os.makedirs(CS_WORKSPACE, exist_ok=True) | |
| cfg_dir = "/root/.config/code-server" | |
| os.makedirs(cfg_dir, exist_ok=True) | |
| with open(os.path.join(cfg_dir, "config.yaml"), "w") as f: | |
| f.write(f"bind-addr: 127.0.0.1:{CS_PORT}\n") | |
| f.write("auth: password\n") | |
| f.write(f"password: {CS_PASSWORD}\n") | |
| f.write("cert: false\n") | |
| env = os.environ.copy() | |
| env.pop("PORT", None) | |
| os.makedirs(os.path.dirname(CS_LOG), exist_ok=True) | |
| log_file = open(CS_LOG, "a") | |
| proc = subprocess.Popen( | |
| ["code-server", "--disable-telemetry", "--disable-update-check", | |
| f"--user-data-dir={CS_USER_DATA_DIR}", | |
| f"--extensions-dir={CS_EXTENSIONS_DIR}", | |
| CS_WORKSPACE], | |
| stdout=log_file, stderr=log_file, | |
| env=env, start_new_session=True, | |
| ) | |
| with open(CS_PID_FILE, "w") as f: | |
| f.write(str(proc.pid)) | |
| log(f"IDE started, PID={proc.pid}") | |
| touch_last_access() | |
| except Exception as e: | |
| log(f"start error: {e}") | |
| finally: | |
| with _lock: | |
| _starting = False | |
| def stop_cs(): | |
| pid = get_cs_pid() | |
| if pid is None: | |
| return | |
| try: | |
| os.kill(pid, signal.SIGTERM) | |
| log(f"IDE (PID={pid}) stopped") | |
| except Exception as e: | |
| log(f"stop error: {e}") | |
| try: | |
| os.remove(CS_PID_FILE) | |
| except Exception: | |
| pass | |
| def idle_checker(): | |
| while True: | |
| time.sleep(CHECK_INTERVAL) | |
| try: | |
| if not is_cs_running(): | |
| continue | |
| try: | |
| mtime = os.path.getmtime(LAST_ACCESS_FILE) | |
| except FileNotFoundError: | |
| mtime = 0 | |
| idle_mins = (time.time() - mtime) / 60 | |
| if idle_mins >= IDLE_MINUTES: | |
| log(f"Idle {idle_mins:.1f} min, stopping IDE...") | |
| stop_cs() | |
| except Exception as e: | |
| log(f"idle_checker error: {e}") | |
| WAKEUP_HTML = """\ | |
| <!DOCTYPE html> | |
| <html lang="zh"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta http-equiv="refresh" content="5;url=/ide/"> | |
| <title>IDE 启动中...</title> | |
| <style> | |
| body{font-family:sans-serif;display:flex;align-items:center; | |
| justify-content:center;height:100vh;margin:0;background:#1e1e1e;color:#ccc} | |
| .box{text-align:center} | |
| .spinner{width:48px;height:48px;border:5px solid #555; | |
| border-top-color:#0078d4;border-radius:50%; | |
| animation:spin 1s linear infinite;margin:0 auto 20px} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="box"> | |
| <div class="spinner"></div> | |
| <p>IDE 正在启动,请稍候...</p> | |
| <p><small>页面将在 5 秒后自动重试,或手动 <a href="/ide/" style="color:#0078d4">刷新</a></small></p> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| class Handler(BaseHTTPRequestHandler): | |
| def log_message(self, fmt, *args): | |
| pass | |
| def do_GET(self): | |
| if self.path.startswith("/wakeup"): | |
| self._handle_wakeup() | |
| elif self.path.startswith("/heartbeat"): | |
| self._handle_heartbeat() | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| def _handle_wakeup(self): | |
| if is_cs_port_ready(): | |
| touch_last_access() | |
| self.send_response(302) | |
| self.send_header("Location", "/ide/") | |
| self.end_headers() | |
| else: | |
| if not is_cs_running(): | |
| threading.Thread(target=start_cs, daemon=True).start() | |
| html = WAKEUP_HTML.encode() | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(html))) | |
| self.end_headers() | |
| self.wfile.write(html) | |
| def _handle_heartbeat(self): | |
| touch_last_access() | |
| self.send_response(204) | |
| self.end_headers() | |
| class UnixSocketHTTPServer(HTTPServer): | |
| address_family = socket.AF_UNIX | |
| def server_bind(self): | |
| try: | |
| os.unlink(self.server_address) | |
| except FileNotFoundError: | |
| pass | |
| super().server_bind() | |
| os.chmod(self.server_address, 0o666) | |
| if __name__ == "__main__": | |
| log(f"ide-manager starting (idle_timeout={IDLE_MINUTES} min, port={CS_PORT})") | |
| threading.Thread(target=idle_checker, daemon=True).start() | |
| server = UnixSocketHTTPServer(SOCK_PATH, Handler) | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| server.server_close() | |
| PYEOF | |
| RUN chmod +x /usr/local/bin/ide-manager | |
| RUN cat <<'EOF' > /usr/local/bin/start-app | |
| #!/bin/bash | |
| set -e | |
| LISTEN_PORT="${PORT:-7860}" | |
| SVC_PORT=8317 | |
| CODE_SERVER_PORT="${CODE_SERVER_PORT:-13337}" | |
| IDE_IDLE_MINUTES="${IDE_IDLE_MINUTES:-30}" | |
| export CODE_SERVER_PORT IDE_IDLE_MINUTES | |
| export CODE_SERVER_PASSWORD="${CODE_SERVER_PASSWORD:-changeme123!}" | |
| DATA_DIR="/root/.rtapi" | |
| CONFIG_FILE="${DATA_DIR}/config.yaml" | |
| mkdir -p "${DATA_DIR}" | |
| python3 /usr/local/bin/sync.py restore | |
| if [ ! -f "${CONFIG_FILE}" ]; then | |
| cat > "${CONFIG_FILE}" <<YAML | |
| port: ${SVC_PORT} | |
| remote-management: | |
| allow-remote: true | |
| secret-key: "${DASHBOARD_PASSWORD:-default-secret}" | |
| disable-control-panel: false | |
| auth-dir: "${DATA_DIR}" | |
| debug: false | |
| logging-to-file: false | |
| usage-statistics-enabled: false | |
| request-retry: 3 | |
| quota-exceeded: | |
| switch-project: true | |
| switch-preview-model: true | |
| api-keys: | |
| $( | |
| if [ -n "${API_KEY_1:-}" ]; then echo " - \"${API_KEY_1}\""; fi | |
| if [ -n "${API_KEY_2:-}" ]; then echo " - \"${API_KEY_2}\""; fi | |
| if [ -n "${API_KEY_3:-}" ]; then echo " - \"${API_KEY_3}\""; fi | |
| if [ -z "${API_KEY_1:-}" ]; then echo " - \"my-default-api-key\""; fi | |
| ) | |
| YAML | |
| fi | |
| mkdir -p /root/.logs | |
| rtapi --config "${CONFIG_FILE}" 2>&1 | tee /root/.logs/svc.log & | |
| python3 /usr/local/bin/ide-manager 2>&1 | tee /root/.logs/ide.log & | |
| for i in $(seq 1 20); do | |
| [ -S /tmp/ide-manager.sock ] && break | |
| sleep 0.5 | |
| done | |
| (while true; do sleep 3600; python3 /usr/local/bin/sync.py backup; done) & | |
| for i in $(seq 1 30); do | |
| if curl -fsS http://127.0.0.1:${SVC_PORT}/ >/dev/null 2>&1; then | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf | |
| cat > /etc/nginx/conf.d/app.conf <<NGINX | |
| upstream ide_mgr { | |
| server unix:/tmp/ide-manager.sock; | |
| } | |
| server { | |
| listen PLACEHOLDER_LISTEN_PORT; | |
| server_name _; | |
| client_max_body_size 100M; | |
| access_log /dev/stdout; | |
| error_log /dev/stderr warn; | |
| absolute_redirect off; | |
| port_in_redirect off; | |
| location /ide/ { | |
| proxy_pass http://127.0.0.1:PLACEHOLDER_CODE_SERVER_PORT/; | |
| proxy_http_version 1.1; | |
| proxy_set_header Host \$host; | |
| proxy_set_header Upgrade \$http_upgrade; | |
| proxy_set_header Connection "upgrade"; | |
| proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; | |
| proxy_set_header X-Forwarded-Proto \$scheme; | |
| proxy_redirect / /ide/; | |
| proxy_read_timeout 86400; | |
| proxy_connect_timeout 2s; | |
| error_page 502 504 @ide_wakeup; | |
| post_action /ide-heartbeat/; | |
| } | |
| location /ide-heartbeat/ { | |
| internal; | |
| rewrite ^ /heartbeat break; | |
| proxy_pass http://ide_mgr; | |
| proxy_connect_timeout 1s; | |
| proxy_read_timeout 2s; | |
| } | |
| location @ide_wakeup { | |
| rewrite ^ /wakeup break; | |
| proxy_pass http://ide_mgr; | |
| proxy_http_version 1.1; | |
| proxy_set_header Host \$host; | |
| proxy_connect_timeout 5s; | |
| proxy_read_timeout 30s; | |
| } | |
| location / { | |
| proxy_pass http://127.0.0.1:PLACEHOLDER_SVC_PORT/; | |
| proxy_http_version 1.1; | |
| proxy_set_header Host \$host; | |
| proxy_set_header Upgrade \$http_upgrade; | |
| proxy_set_header Connection "upgrade"; | |
| proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; | |
| proxy_set_header X-Forwarded-Proto \$scheme; | |
| proxy_read_timeout 86400; | |
| } | |
| } | |
| NGINX | |
| sed -i \ | |
| "s/PLACEHOLDER_LISTEN_PORT/${LISTEN_PORT}/g; \ | |
| s/PLACEHOLDER_CODE_SERVER_PORT/${CODE_SERVER_PORT}/g; \ | |
| s/PLACEHOLDER_SVC_PORT/${SVC_PORT}/g" \ | |
| /etc/nginx/conf.d/app.conf | |
| nginx -t | |
| exec nginx -g 'daemon off; error_log /dev/stderr warn;' | |
| EOF | |
| RUN chmod +x /usr/local/bin/start-app | |
| EXPOSE 7860 | |
| CMD ["/usr/local/bin/start-app"] | |