第一次提交
This commit is contained in:
13
deploy-control/Dockerfile
Normal file
13
deploy-control/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker.io \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server.py /app/server.py
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
CMD ["python", "/app/server.py"]
|
||||
49
deploy-control/README.md
Normal file
49
deploy-control/README.md
Normal file
@ -0,0 +1,49 @@
|
||||
# deploy-control
|
||||
|
||||
部署机接口:
|
||||
|
||||
- 给 `auth-portal` 提供实例创建和实例解析 API
|
||||
- 调用 `app-instance/create-instance.sh`
|
||||
- 在实例创建后刷新 `router-proxy`
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /healthz`
|
||||
- `POST /api/instances/register`
|
||||
- `POST /api/instances/resolve`
|
||||
- `DELETE /api/instances/{instance_id}`
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
- `DEPLOY_CONTROL_API_TOKEN`
|
||||
- `APP_INSTANCE_API_KEY`
|
||||
- `DEFAULT_AUTHZ_BASE_URL`
|
||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||
- `DEPLOY_PUBLIC_PORT`
|
||||
- `DEPLOY_PUBLIC_SCHEME`
|
||||
- `APP_INSTANCE_NETWORK_NAME`
|
||||
|
||||
默认实例 URL 形如:
|
||||
|
||||
```text
|
||||
http://<instance-slug>.127.0.0.1.nip.io:8088
|
||||
```
|
||||
|
||||
实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`。
|
||||
|
||||
## 本机启动
|
||||
|
||||
```bash
|
||||
cd /home/ivan/xuan/nano_project/deploy-control
|
||||
python3 server.py
|
||||
```
|
||||
|
||||
## Docker 启动
|
||||
|
||||
如果要容器化运行,需要挂载:
|
||||
|
||||
- Docker socket:`/var/run/docker.sock`
|
||||
- `/home/ivan/xuan/nano_project/app-instance`
|
||||
- `/home/ivan/xuan/nano_project/router-proxy`
|
||||
|
||||
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
||||
BIN
deploy-control/__pycache__/server.cpython-310.pyc
Normal file
BIN
deploy-control/__pycache__/server.cpython-310.pyc
Normal file
Binary file not shown.
379
deploy-control/server.py
Executable file
379
deploy-control/server.py
Executable file
@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error as urllib_error
|
||||
from urllib import request as urllib_request
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
APP_INSTANCE_DIR = Path(os.environ.get("APP_INSTANCE_DIR", BASE_DIR.parent / "app-instance")).resolve()
|
||||
ROUTER_PROXY_DIR = Path(os.environ.get("ROUTER_PROXY_DIR", BASE_DIR.parent / "router-proxy")).resolve()
|
||||
CREATE_INSTANCE_SCRIPT = Path(
|
||||
os.environ.get("CREATE_INSTANCE_SCRIPT", APP_INSTANCE_DIR / "create-instance.sh")
|
||||
).resolve()
|
||||
REMOVE_INSTANCE_SCRIPT = Path(
|
||||
os.environ.get("REMOVE_INSTANCE_SCRIPT", APP_INSTANCE_DIR / "remove-instance.sh")
|
||||
).resolve()
|
||||
REGISTRY_TOOL = Path(
|
||||
os.environ.get("REGISTRY_TOOL", APP_INSTANCE_DIR / "instance-registry.py")
|
||||
).resolve()
|
||||
REGISTRY_PATH = Path(
|
||||
os.environ.get("REGISTRY_PATH", APP_INSTANCE_DIR / "runtime" / "registry" / "instances.json")
|
||||
).resolve()
|
||||
PROXY_RELOAD_SCRIPT = Path(
|
||||
os.environ.get("PROXY_RELOAD_SCRIPT", ROUTER_PROXY_DIR / "reload-proxy.sh")
|
||||
).resolve()
|
||||
|
||||
API_TOKEN = os.environ.get("DEPLOY_CONTROL_API_TOKEN", "").strip()
|
||||
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "nano/app-instance:latest").strip()
|
||||
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "nano-instance-edge").strip()
|
||||
DEFAULT_PROVIDER = os.environ.get("APP_INSTANCE_PROVIDER", "openai").strip()
|
||||
DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip()
|
||||
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
|
||||
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
|
||||
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
||||
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
|
||||
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip()
|
||||
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
|
||||
PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088")
|
||||
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
|
||||
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
|
||||
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
|
||||
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
||||
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()).strip("-")
|
||||
if not slug:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "instance id produced an empty slug")
|
||||
return slug
|
||||
|
||||
|
||||
def run_command(args: list[str], *, cwd: Path | None = None, extra_env: dict[str, str] | None = None) -> str:
|
||||
env = os.environ.copy()
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or completed.stdout.strip() or "command failed"
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, detail)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def load_registry() -> dict[str, Any]:
|
||||
if not REGISTRY_PATH.exists():
|
||||
return {"instances": []}
|
||||
try:
|
||||
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {"instances": []}
|
||||
if not isinstance(data, dict):
|
||||
return {"instances": []}
|
||||
if not isinstance(data.get("instances"), list):
|
||||
data["instances"] = []
|
||||
return data
|
||||
|
||||
|
||||
def get_registry_record(*, instance_id: str | None = None, username: str | None = None) -> dict[str, Any] | None:
|
||||
args = [str(REGISTRY_TOOL), "--registry", str(REGISTRY_PATH), "get"]
|
||||
if instance_id:
|
||||
args.extend(["--instance-id", instance_id])
|
||||
if username:
|
||||
args.extend(["--username", username])
|
||||
completed = subprocess.run(args, text=True, capture_output=True, check=False)
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def ensure_network() -> None:
|
||||
result = subprocess.run(
|
||||
["docker", "network", "inspect", INSTANCE_NETWORK_NAME],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
run_command(["docker", "network", "create", INSTANCE_NETWORK_NAME])
|
||||
|
||||
|
||||
def ensure_proxy() -> None:
|
||||
if AUTO_START_PROXY:
|
||||
run_command([str(PROXY_RELOAD_SCRIPT), "--start-if-missing"])
|
||||
return
|
||||
run_command([str(PROXY_RELOAD_SCRIPT)])
|
||||
|
||||
|
||||
def build_public_host(*, slug: str, instance_id: str, username: str) -> str:
|
||||
try:
|
||||
host = PUBLIC_HOST_TEMPLATE.format(
|
||||
slug=slug,
|
||||
instance_id=instance_id,
|
||||
username=slugify(username),
|
||||
base_domain=PUBLIC_BASE_DOMAIN,
|
||||
).strip()
|
||||
except KeyError as exc:
|
||||
raise ApiError(HTTPStatus.INTERNAL_SERVER_ERROR, f"invalid host template key: {exc}") from exc
|
||||
if not host:
|
||||
raise ApiError(HTTPStatus.INTERNAL_SERVER_ERROR, "public host template produced an empty host")
|
||||
return host
|
||||
|
||||
|
||||
def build_public_url(host: str) -> str:
|
||||
default_port = 80 if PUBLIC_SCHEME == "http" else 443
|
||||
netloc = host if PUBLIC_PORT == default_port else f"{host}:{PUBLIC_PORT}"
|
||||
return f"{PUBLIC_SCHEME}://{netloc}"
|
||||
|
||||
|
||||
def wait_for_backend(record: dict[str, Any]) -> None:
|
||||
host_port = int(record.get("host_port", 0) or 0)
|
||||
if host_port <= 0:
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance host port missing from registry")
|
||||
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
||||
target = f"http://127.0.0.1:{host_port}/api/ping"
|
||||
last_error = "backend not ready"
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib_request.urlopen(target, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if payload.get("message") == "pong":
|
||||
return
|
||||
last_error = f"unexpected ping response from {target}"
|
||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(HEALTH_INTERVAL_SECONDS)
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance health check failed: {last_error}")
|
||||
|
||||
|
||||
def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
username = str(payload.get("username", "") or "").strip()
|
||||
password = str(payload.get("password", "") or "")
|
||||
email = str(payload.get("email", "") or "").strip()
|
||||
if not username:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "username is required")
|
||||
if not password:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "password is required")
|
||||
|
||||
instance_id = str(payload.get("instance_id", "") or username).strip()
|
||||
slug = slugify(instance_id)
|
||||
existing = get_registry_record(username=username) or get_registry_record(instance_id=instance_id)
|
||||
created = False
|
||||
|
||||
if existing is None:
|
||||
ensure_network()
|
||||
public_host = build_public_host(slug=slug, instance_id=instance_id, username=username)
|
||||
public_url = build_public_url(public_host)
|
||||
provider = str(payload.get("provider", "") or DEFAULT_PROVIDER).strip() or DEFAULT_PROVIDER
|
||||
model = str(payload.get("model", "") or DEFAULT_MODEL).strip() or DEFAULT_MODEL
|
||||
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
|
||||
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
|
||||
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
||||
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
||||
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
||||
|
||||
if not api_key:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "api key is required for new instances")
|
||||
|
||||
command = [
|
||||
str(CREATE_INSTANCE_SCRIPT),
|
||||
"--image",
|
||||
image_name,
|
||||
"--instance-id",
|
||||
instance_id,
|
||||
"--auth-username",
|
||||
username,
|
||||
"--auth-password",
|
||||
password,
|
||||
"--username",
|
||||
username,
|
||||
"--email",
|
||||
email,
|
||||
"--provider",
|
||||
provider,
|
||||
"--model",
|
||||
model,
|
||||
"--api-key",
|
||||
api_key,
|
||||
"--backend-name",
|
||||
backend_name,
|
||||
"--public-url",
|
||||
public_url,
|
||||
"--instance-host",
|
||||
public_host,
|
||||
"--network",
|
||||
INSTANCE_NETWORK_NAME,
|
||||
]
|
||||
if api_base:
|
||||
command.extend(["--api-base", api_base])
|
||||
if authz_base_url:
|
||||
command.extend(["--authz-base-url", authz_base_url])
|
||||
if payload.get("replace") is True:
|
||||
command.append("--replace")
|
||||
|
||||
run_command(command, cwd=APP_INSTANCE_DIR)
|
||||
existing = get_registry_record(instance_id=instance_id)
|
||||
created = True
|
||||
|
||||
if existing is None:
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance was created but registry record is missing")
|
||||
|
||||
wait_for_backend(existing)
|
||||
ensure_proxy()
|
||||
|
||||
return {
|
||||
"created": created,
|
||||
"instance": existing,
|
||||
"public_url": str(existing.get("public_url", "") or ""),
|
||||
"frontend_base_url": str(existing.get("frontend_base_url", "") or existing.get("public_url", "") or ""),
|
||||
"api_base_url": str(existing.get("api_base_url", "") or existing.get("public_url", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def resolve_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
username = str(payload.get("username", "") or "").strip()
|
||||
if not username:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "username is required")
|
||||
record = get_registry_record(username=username)
|
||||
if record is None:
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "instance not found")
|
||||
return {
|
||||
"instance": record,
|
||||
"public_url": str(record.get("public_url", "") or ""),
|
||||
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
|
||||
"api_base_url": str(record.get("api_base_url", "") or record.get("public_url", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def remove_instance(instance_id: str, purge_data: bool) -> dict[str, Any]:
|
||||
if not instance_id.strip():
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "instance id is required")
|
||||
command = [str(REMOVE_INSTANCE_SCRIPT), "--instance-id", instance_id]
|
||||
if purge_data:
|
||||
command.append("--purge-data")
|
||||
output = run_command(command, cwd=APP_INSTANCE_DIR)
|
||||
ensure_proxy()
|
||||
result: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "deploy-control/0.1"
|
||||
|
||||
def _json_response(self, status_code: int, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_json_body(self) -> dict[str, Any]:
|
||||
raw_length = self.headers.get("Content-Length", "0").strip() or "0"
|
||||
try:
|
||||
length = int(raw_length)
|
||||
except ValueError as exc:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "invalid content length") from exc
|
||||
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "invalid JSON body") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ApiError(HTTPStatus.BAD_REQUEST, "JSON body must be an object")
|
||||
return payload
|
||||
|
||||
def _require_auth(self) -> None:
|
||||
if not API_TOKEN:
|
||||
return
|
||||
authorization = self.headers.get("Authorization", "").strip()
|
||||
expected = f"Bearer {API_TOKEN}"
|
||||
if authorization != expected:
|
||||
raise ApiError(HTTPStatus.UNAUTHORIZED, "unauthorized")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
try:
|
||||
if self.path == "/healthz":
|
||||
self._json_response(HTTPStatus.OK, {"ok": True, "instances": len(load_registry().get("instances", []))})
|
||||
return
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "not found")
|
||||
except ApiError as exc:
|
||||
self._json_response(exc.status_code, {"detail": exc.detail})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
try:
|
||||
self._require_auth()
|
||||
if self.path == "/api/instances/register":
|
||||
payload = self._read_json_body()
|
||||
self._json_response(HTTPStatus.OK, create_or_get_instance(payload))
|
||||
return
|
||||
if self.path == "/api/instances/resolve":
|
||||
payload = self._read_json_body()
|
||||
self._json_response(HTTPStatus.OK, resolve_instance(payload))
|
||||
return
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "not found")
|
||||
except ApiError as exc:
|
||||
self._json_response(exc.status_code, {"detail": exc.detail})
|
||||
|
||||
def do_DELETE(self) -> None: # noqa: N802
|
||||
try:
|
||||
self._require_auth()
|
||||
if not self.path.startswith("/api/instances/"):
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "not found")
|
||||
instance_id = self.path.rsplit("/", 1)[-1]
|
||||
purge_data = self.headers.get("X-Purge-Data", "").strip() == "1"
|
||||
self._json_response(HTTPStatus.OK, remove_instance(instance_id, purge_data))
|
||||
except ApiError as exc:
|
||||
self._json_response(exc.status_code, {"detail": exc.detail})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
server = ThreadingHTTPServer((SERVER_HOST, SERVER_PORT), Handler)
|
||||
print(f"deploy-control listening on {SERVER_HOST}:{SERVER_PORT}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user