更新
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
# AGENTS.md — Heicode 单仓导航(给 Codex / 助手)
|
||||||
|
|
||||||
|
本文是 **仓库根级** 的快速地图与协作约定。细分栈的规则请看各子目录自带文档(避免重复与漂移)。
|
||||||
|
|
||||||
|
## 仓库地图(explore 摘要)
|
||||||
|
|
||||||
|
| 路径 | 角色 | 栈 / 备注 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `cc-haha/` | **Heicode** 客户端:CLI(Ink)+ 本地 HTTP/WS 服务 + **Desktop**(Tauri + React) | Bun + TypeScript;产品入口 `bin/heicode` |
|
||||||
|
| `heicode/` | **Heicode Manager**:网关 + 管理控制台 | Go(Gin/GORM)+ `web/default` 前端(Bun/Rsbuild/React) |
|
||||||
|
| `website/` | 产品介绍站点 | Next.js;根 `docker-compose.yml` 提供 `heicode-www` :8888 |
|
||||||
|
| `docs/` | 愿景、**里程碑**(`docs/milestones/`)、**Agnet 集成**(`docs/integration/`) | Markdown |
|
||||||
|
|
||||||
|
根 `package.json` 仅少量 workspace 级依赖(如适配器用到的包);**主要开发依赖在 `cc-haha/package.json`**。
|
||||||
|
|
||||||
|
## 权威子文档(改代码前先打开对应一篇)
|
||||||
|
|
||||||
|
- **客户端(cc-haha)**:[`cc-haha/AGENTS.md`](./cc-haha/AGENTS.md) — 目录结构、运行命令、测试、提交约定。
|
||||||
|
- **网关(heicode)**:[`heicode/AGENTS.md`](./heicode/AGENTS.md) — Go 分层、JSON/i18n/DB 规则等。
|
||||||
|
- **产品愿景与交付节奏**:[`docs/vision-heicode-full-stack-agentic-dev.md`](./docs/vision-heicode-full-stack-agentic-dev.md)、[`docs/milestones/README.md`](./docs/milestones/README.md)。
|
||||||
|
|
||||||
|
## 最小开发闭环
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 依赖(客户端主体)
|
||||||
|
cd cc-haha && bun install
|
||||||
|
|
||||||
|
# 终端 A:本地 API(桌面端依赖)
|
||||||
|
bun run src/server/index.ts
|
||||||
|
|
||||||
|
# 终端 B:桌面
|
||||||
|
cd desktop && bun run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
联调本机 Manager(heicode)时常见:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
更多变量见 `cc-haha/src/server/config/providerPresets.ts` 与 `cc-haha/src/server/api/heicode-auth.ts`(如 `HEICODE_<PROVIDER>_OAUTH_*`)。
|
||||||
|
|
||||||
|
## Heicode ↔ Manager 关键触摸点(代码索引)
|
||||||
|
|
||||||
|
- **客户端登录 / Provider**:`cc-haha/src/server/api/heicode-auth.ts`,路由前缀 `/api/heicode-auth/*`(由 `cc-haha/src/server/router.ts` 挂载)。
|
||||||
|
- **模型发现**:`cc-haha/src/server/services/providerService.ts`、`cc-haha/src/server/api/providers.ts`。
|
||||||
|
- **Provider 预设**:`cc-haha/src/server/config/providerPresets.json` + `providerPresets.ts`。
|
||||||
|
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(例如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
|
||||||
|
|
||||||
|
## 协作约定(根级)
|
||||||
|
|
||||||
|
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/AGENTS.md` 为准;客户端 TS/React 以 `cc-haha/AGENTS.md` 为准。
|
||||||
|
2. **小步提交**:沿用历史风格(如 `feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
|
||||||
|
3. **集成与「SK」边界**:平台契约见 [`docs/integration/agnet-platform-api-design.md`](./docs/integration/agnet-platform-api-design.md),里程碑见 `docs/milestones/`。
|
||||||
|
4. **不要臆测计费**:计费与订阅在平台侧,不在 Heicode 客户端内实现。
|
||||||
|
|
||||||
|
## Docker / 站点
|
||||||
|
|
||||||
|
- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。
|
||||||
|
- Manager 本地编排以 `heicode/docker-compose.yml` 及仓库内 override 为准(若存在)。
|
||||||
|
- **VM / 生产同步代码**:优先在目标机上 **`git clone` / `git pull`** 与本仓库远程一致;**不要**用 `scp` 传整份源码或 compose(仅在仓库不可用或紧急热修时例外)。
|
||||||
|
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 **`sudo docker image prune -f`**,清掉重建产生的悬空层;定期可 **`sudo docker system prune -f`**(不删仍在使用的卷)。避免只叠新镜像、旧层占满磁盘。
|
||||||
|
|
||||||
|
## 当前线上入口(2026-04)
|
||||||
|
|
||||||
|
- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net`
|
||||||
|
- **Heicode Manager(生产)**:`https://code.xinghanlab.com/`
|
||||||
|
|
||||||
|
官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。
|
||||||
|
|
||||||
|
## Manager 生产拓扑(Azure)
|
||||||
|
|
||||||
|
- 应用层:Azure VM 上运行 `heicode`(当前用 Docker 容器承载应用进程)。
|
||||||
|
- 数据库与缓存:使用 Azure 托管服务(不在 VM 上跑 PostgreSQL/Redis 容器)
|
||||||
|
- PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode`
|
||||||
|
- Redis: `heicode.redis.cache.windows.net:6380`(TLS)
|
||||||
|
- 容器落盘:VM 仅承载应用进程;**容器内产生的文件**(如 `/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘,便于备份与重建容器后不丢。编排见 `heicode/docker-compose.azure-vm.yml`,目录由环境变量 `HEICODE_DATA_ROOT` 指定。
|
||||||
|
- 说明:不要在生产 VM 上再启本地 `postgres`/`redis` 容器并指向业务库,避免与托管实例混淆。
|
||||||
|
- 镜像清理:发版构建后删除悬空/旧镜像层(见上文「VM 部署后清镜像」),防止 OS 盘被 Docker 占满。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*若本文件与子目录 `AGENTS.md` / `AGENTS.md` 冲突,以子目录为准并及时更新根文件摘要。*
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-shot: create a dedicated NewAPI sk- token for the downstream
|
||||||
|
"蜂群程序" (swarm bee program) and print the base URL + key.
|
||||||
|
|
||||||
|
Why direct DB INSERT instead of the /api/token POST endpoint:
|
||||||
|
- that endpoint requires a logged-in admin session cookie we
|
||||||
|
don't have a clean way to mint headless;
|
||||||
|
- the token table shape is stable (new-api fork has tracked
|
||||||
|
these columns for two years).
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- Attaches the token to the platform owner user (whose email
|
||||||
|
we look up first).
|
||||||
|
- Marks UnlimitedQuota=true so this token consumes user-row
|
||||||
|
quota (no separate billing surface to manage).
|
||||||
|
- HideFromUserUI=false so the operator can revoke it from the
|
||||||
|
/keys page if needed.
|
||||||
|
- Sets a descriptive Name + Remark per the user's "备注好"
|
||||||
|
requirement.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = "20.24.50.121"
|
||||||
|
USER = "heicode"
|
||||||
|
PASS = os.environ.get("MGR_PASS", "")
|
||||||
|
if not PASS:
|
||||||
|
print("MGR_PASS not set", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
DB_PASS = "Myadmin@123456."
|
||||||
|
DB_HOST = "heicode.postgres.database.azure.com"
|
||||||
|
DB_USER = "heicode"
|
||||||
|
DB_NAME = "heicode"
|
||||||
|
|
||||||
|
# new-api token keys are 48-char alphanum (no padding). Match that
|
||||||
|
# exactly so admin tools / dashboards parse it.
|
||||||
|
ALPHABET = string.ascii_letters + string.digits
|
||||||
|
RAW_KEY = "".join(secrets.choice(ALPHABET) for _ in range(48))
|
||||||
|
# Token bearer format on the wire is "sk-<48 chars>".
|
||||||
|
|
||||||
|
SQL_FIND = (
|
||||||
|
"SELECT id, username, email FROM users "
|
||||||
|
"WHERE email IN ('zsbgnw@gmail.com','chenchen@xinghanlab.com') "
|
||||||
|
" OR username IN ('chenchen','root') "
|
||||||
|
" OR role >= 10 "
|
||||||
|
"ORDER BY role DESC, id ASC LIMIT 1;"
|
||||||
|
)
|
||||||
|
|
||||||
|
INSERT_SQL_TEMPLATE = (
|
||||||
|
"INSERT INTO tokens "
|
||||||
|
'(user_id, name, "key", status, created_time, accessed_time, '
|
||||||
|
"expired_time, remain_quota, used_quota, unlimited_quota, "
|
||||||
|
"model_limits_enabled, model_limits, allow_ips, "
|
||||||
|
'"group", cross_group_retry, hide_from_user_ui) '
|
||||||
|
"VALUES "
|
||||||
|
"({user_id}, '蜂群程序 (Swarm Bot)', '{key}', 1, "
|
||||||
|
"EXTRACT(EPOCH FROM NOW())::bigint, EXTRACT(EPOCH FROM NOW())::bigint, "
|
||||||
|
"-1, 0, 0, true, false, '', '', '', false, false) "
|
||||||
|
"RETURNING id, name, \"key\";"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect(HOST, 22, USER, PASS, look_for_keys=False, allow_agent=False, timeout=30)
|
||||||
|
|
||||||
|
def psql(sql: str) -> str:
|
||||||
|
# Pipe SQL via stdin so quoted identifiers like "group" survive
|
||||||
|
# without shell-escape hell.
|
||||||
|
cmd = (
|
||||||
|
f"PGPASSWORD='{DB_PASS}' psql "
|
||||||
|
f"'sslmode=require host={DB_HOST} user={DB_USER} dbname={DB_NAME}' "
|
||||||
|
f"-At -F'|' -v ON_ERROR_STOP=1"
|
||||||
|
)
|
||||||
|
stdin, stdout, stderr = c.exec_command(cmd, timeout=60, get_pty=False)
|
||||||
|
stdin.write(sql)
|
||||||
|
stdin.channel.shutdown_write()
|
||||||
|
out = stdout.read().decode("utf-8", "replace").strip()
|
||||||
|
err = stderr.read().decode("utf-8", "replace").strip()
|
||||||
|
if err:
|
||||||
|
print(f"[psql stderr] {err}", file=sys.stderr)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Step 1: find owner user
|
||||||
|
print(f"--- Looking up platform owner user ---", flush=True)
|
||||||
|
owner_row = psql(SQL_FIND)
|
||||||
|
if not owner_row:
|
||||||
|
print("ERROR: no owner user found", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
fields = owner_row.split("|")
|
||||||
|
owner_id = int(fields[0])
|
||||||
|
owner_username = fields[1] if len(fields) > 1 else ""
|
||||||
|
owner_email = fields[2] if len(fields) > 2 else ""
|
||||||
|
print(f"Owner: id={owner_id} username={owner_username} email={owner_email}")
|
||||||
|
|
||||||
|
# Step 2: insert the token row
|
||||||
|
print(f"\n--- Inserting dedicated 蜂群程序 token ---", flush=True)
|
||||||
|
insert_sql = INSERT_SQL_TEMPLATE.format(user_id=owner_id, key=RAW_KEY)
|
||||||
|
inserted = psql(insert_sql)
|
||||||
|
if not inserted:
|
||||||
|
print("ERROR: INSERT returned nothing", file=sys.stderr)
|
||||||
|
sys.exit(4)
|
||||||
|
fields = inserted.split("|")
|
||||||
|
token_id = int(fields[0])
|
||||||
|
token_name = fields[1]
|
||||||
|
token_key = fields[2]
|
||||||
|
print(f"Token row inserted: id={token_id} name={token_name}")
|
||||||
|
|
||||||
|
# Step 3: print result
|
||||||
|
base_url = "https://code.xinghanlab.com"
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("NEWAPI CREDENTIAL — 蜂群程序 (SWARM BOT)")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Base URL (OpenAI compat): {base_url}/v1")
|
||||||
|
print(f"Base URL (Anthropic compat): {base_url}/v1/messages")
|
||||||
|
print(f"Base URL (raw root): {base_url}")
|
||||||
|
print(f"")
|
||||||
|
print(f"API Key (Authorization: Bearer ...):")
|
||||||
|
print(f" sk-{token_key}")
|
||||||
|
print(f"")
|
||||||
|
print(f"Token row id: {token_id}")
|
||||||
|
print(f"Owner user id: {owner_id} ({owner_email})")
|
||||||
|
print(f"Quota model: unlimited (consumes the owner-account quota)")
|
||||||
|
print(f"Expires: never")
|
||||||
|
print(f"Manage / revoke from: {base_url}/keys")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-shot deploy script for the Heicode Manager Azure VM. Designed to be
|
||||||
|
idempotent — running it twice in a row converges to the same state.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. SSH into the VM with credentials supplied via env (NEVER hard-coded
|
||||||
|
in the repo — the launcher passes them on stdin).
|
||||||
|
2. cd into the manager checkout, git pull origin main.
|
||||||
|
3. docker compose pull + up -d --build, then prune dangling images.
|
||||||
|
4. Wait for /api/status to return 200.
|
||||||
|
5. Print container status + tail of the manager logs.
|
||||||
|
|
||||||
|
Failure mode: if any step exits non-zero, the whole script aborts and
|
||||||
|
prints the offending command's stderr — caller (this Claude session)
|
||||||
|
escalates to the user instead of silently moving on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = os.environ.get("MGR_HOST", "20.24.50.121")
|
||||||
|
USER = os.environ.get("MGR_USER", "heicode")
|
||||||
|
PASS = os.environ.get("MGR_PASS", "")
|
||||||
|
PORT = int(os.environ.get("MGR_PORT", "22"))
|
||||||
|
|
||||||
|
if not PASS:
|
||||||
|
print("MGR_PASS not set", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def run(client: paramiko.SSHClient, cmd: str, timeout: int = 600) -> int:
|
||||||
|
"""Stream a remote command's output to local stdout/stderr."""
|
||||||
|
print(f"\n$ {cmd}", flush=True)
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=True)
|
||||||
|
for line in iter(stdout.readline, ""):
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
sys.stdout.write(line)
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = stdout.channel.recv_exit_status()
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
if err.strip():
|
||||||
|
sys.stderr.write(err)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
def must(rc: int, label: str) -> None:
|
||||||
|
if rc != 0:
|
||||||
|
print(f"\nFAIL: {label} exit={rc}", file=sys.stderr)
|
||||||
|
sys.exit(rc or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
print(f"Connecting {USER}@{HOST}:{PORT} ...", flush=True)
|
||||||
|
client.connect(
|
||||||
|
hostname=HOST,
|
||||||
|
port=PORT,
|
||||||
|
username=USER,
|
||||||
|
password=PASS,
|
||||||
|
look_for_keys=False,
|
||||||
|
allow_agent=False,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Locate the manager checkout. We try a few likely paths instead of
|
||||||
|
# hardcoding one — first match wins.
|
||||||
|
probe = (
|
||||||
|
"for p in "
|
||||||
|
"~/heicode-mananger ~/heicode ~/heicode-mananger/heicode "
|
||||||
|
"/opt/heicode-mananger /home/heicode/heicode-mananger; do "
|
||||||
|
" if [ -d $p/.git ]; then echo FOUND=$p; break; fi; "
|
||||||
|
"done"
|
||||||
|
)
|
||||||
|
stdin, stdout, _ = client.exec_command(probe, timeout=20)
|
||||||
|
out = stdout.read().decode().strip()
|
||||||
|
print(out)
|
||||||
|
repo = None
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("FOUND="):
|
||||||
|
repo = line.split("=", 1)[1]
|
||||||
|
break
|
||||||
|
if not repo:
|
||||||
|
print("Could not locate manager git checkout on VM", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
print(f"Using repo: {repo}")
|
||||||
|
|
||||||
|
# Find docker-compose file we'll target.
|
||||||
|
compose = "docker-compose.azure-vm.yml"
|
||||||
|
must(run(client, f"test -f {repo}/heicode/{compose} || ls {repo}/heicode/docker-compose*.yml"),
|
||||||
|
"locate compose file")
|
||||||
|
|
||||||
|
# Pull latest code.
|
||||||
|
must(run(client, f"cd {repo} && git fetch origin && git checkout main && git pull --ff-only origin main"),
|
||||||
|
"git pull")
|
||||||
|
|
||||||
|
# Show what we have locally now.
|
||||||
|
run(client, f"cd {repo} && git log --oneline -3")
|
||||||
|
|
||||||
|
# Rebuild + restart.
|
||||||
|
must(run(client,
|
||||||
|
f"cd {repo}/heicode && sudo docker compose -f {compose} up -d --build 2>&1",
|
||||||
|
timeout=1800),
|
||||||
|
"compose up")
|
||||||
|
|
||||||
|
# Clean dangling images per CLAUDE.md.
|
||||||
|
run(client, "sudo docker image prune -f")
|
||||||
|
|
||||||
|
# Wait for /api/status to come back.
|
||||||
|
print("\nWaiting for /api/status to return 200 (max 90s) ...")
|
||||||
|
deadline = time.time() + 90
|
||||||
|
status_ok = False
|
||||||
|
while time.time() < deadline:
|
||||||
|
rc = run(client,
|
||||||
|
"curl -fsS -o /dev/null -w 'HTTP_%{http_code}\\n' https://code.xinghanlab.com/api/status")
|
||||||
|
if rc == 0:
|
||||||
|
status_ok = True
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
|
if not status_ok:
|
||||||
|
print("FAIL: /api/status did not return 200 within 90s", file=sys.stderr)
|
||||||
|
run(client, f"cd {repo}/heicode && sudo docker compose -f {compose} logs --tail=80 heicode")
|
||||||
|
sys.exit(4)
|
||||||
|
|
||||||
|
print("\n--- Final state ---")
|
||||||
|
run(client, "sudo docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}'")
|
||||||
|
run(client, f"cd {repo}/heicode && sudo docker compose -f {compose} logs --tail=20 heicode")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print("\nDEPLOY OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Look up which user account to attach the swarm-program token to."""
|
||||||
|
import os, sys, paramiko
|
||||||
|
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect("20.24.50.121", 22, "heicode", os.environ["MGR_PASS"],
|
||||||
|
look_for_keys=False, allow_agent=False, timeout=30)
|
||||||
|
|
||||||
|
sql = '''
|
||||||
|
SELECT id, username, email, "group", role, status
|
||||||
|
FROM users
|
||||||
|
WHERE email IN ('zsbgnw@gmail.com', 'chenchen@xinghanlab.com')
|
||||||
|
OR username = 'chenchen'
|
||||||
|
OR role >= 10
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 10;
|
||||||
|
'''
|
||||||
|
# Wrap via psql exec inside the heicode container.
|
||||||
|
cmd = (
|
||||||
|
'sudo docker exec heicode sh -c '
|
||||||
|
+ '"PGPASSWORD=\\"Myadmin@123456.\\" psql '
|
||||||
|
+ '-h heicode.postgres.database.azure.com -U heicode heicode '
|
||||||
|
+ "-c \\\"" + sql.replace("\n", " ").replace('"', '\\"\\"') + "\\\"\""
|
||||||
|
)
|
||||||
|
_, o, e = c.exec_command(cmd, timeout=60, get_pty=True)
|
||||||
|
for line in iter(o.readline, ""):
|
||||||
|
if not line: break
|
||||||
|
sys.stdout.write(line.encode("ascii","replace").decode("ascii"))
|
||||||
|
sys.stdout.flush()
|
||||||
|
err = e.read().decode("ascii","replace")
|
||||||
|
if err.strip():
|
||||||
|
sys.stderr.write(err)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Force-recreate the manager container after a build that didn't swap.
|
||||||
|
|
||||||
|
Why this exists: `docker compose up -d --build` rebuilds the image but
|
||||||
|
won't recreate the running container if compose decides the spec hasn't
|
||||||
|
changed. On the Azure VM we've hit cases where the smoke test shows the
|
||||||
|
old container is still up. This script:
|
||||||
|
1. Inspects current container start time
|
||||||
|
2. Runs `compose up -d --force-recreate --build`
|
||||||
|
3. Confirms new start time
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = os.environ.get("MGR_HOST", "20.24.50.121")
|
||||||
|
USER = os.environ.get("MGR_USER", "heicode")
|
||||||
|
PASS = os.environ.get("MGR_PASS", "")
|
||||||
|
|
||||||
|
if not PASS:
|
||||||
|
print("MGR_PASS not set", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def run(client, cmd, timeout=900):
|
||||||
|
print(f"\n$ {cmd}", flush=True)
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=True)
|
||||||
|
out_chunks = []
|
||||||
|
for raw in iter(stdout.readline, ""):
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
# Strip non-ASCII to dodge Windows cp936 encoding errors.
|
||||||
|
clean = raw.encode("ascii", "replace").decode("ascii")
|
||||||
|
out_chunks.append(clean)
|
||||||
|
sys.stdout.write(clean)
|
||||||
|
sys.stdout.flush()
|
||||||
|
rc = stdout.channel.recv_exit_status()
|
||||||
|
return rc, "".join(out_chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(
|
||||||
|
hostname=HOST, port=22, username=USER, password=PASS,
|
||||||
|
look_for_keys=False, allow_agent=False, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Locate repo + compose file
|
||||||
|
rc, repo_out = run(client,
|
||||||
|
"for p in ~/heicode-mananger ~/heicode ~/heicode-mananger/heicode "
|
||||||
|
"/opt/heicode-mananger /home/heicode/heicode-mananger; do "
|
||||||
|
" if [ -d $p/.git ]; then echo FOUND=$p; break; fi; done")
|
||||||
|
repo = None
|
||||||
|
for line in repo_out.splitlines():
|
||||||
|
if "FOUND=" in line:
|
||||||
|
repo = line.split("=", 1)[1].strip()
|
||||||
|
break
|
||||||
|
if not repo:
|
||||||
|
print("repo not found", file=sys.stderr); sys.exit(3)
|
||||||
|
|
||||||
|
run(client, "sudo docker ps --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}\\t{{.CreatedAt}}'")
|
||||||
|
run(client, f"cd {repo} && git log --oneline -1")
|
||||||
|
|
||||||
|
# Pin to latest commit + force recreate.
|
||||||
|
run(client,
|
||||||
|
f"cd {repo}/heicode && sudo docker compose -f docker-compose.azure-vm.yml up -d --force-recreate --build heicode 2>&1",
|
||||||
|
timeout=1800)
|
||||||
|
|
||||||
|
run(client, "sleep 8")
|
||||||
|
run(client, "sudo docker ps --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}\\t{{.CreatedAt}}'")
|
||||||
|
run(client, "sudo docker image prune -f")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print("\nFORCE RECREATE DONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Post-deploy smoke test for Heicode Manager. Runs entirely from this
|
||||||
|
laptop against https://code.xinghanlab.com — no SSH required.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. /api/status returns 200 with success:true (proves the binary
|
||||||
|
is running and the DB is reachable).
|
||||||
|
2. New build hash present (HTML title or static asset includes a
|
||||||
|
fresh content hash, indirectly confirming the new image is live).
|
||||||
|
3. /api/devices returns 401 when called without auth (proves
|
||||||
|
the route is wired AND not accidentally public).
|
||||||
|
4. /api/devices/pair returns 401 without auth (same reasoning).
|
||||||
|
5. /v1/messages with a clearly-invalid V2 envelope returns 401
|
||||||
|
AND carries X-Heicode-Auth-Error + X-Heicode-Server-Time headers
|
||||||
|
— this is the V2 diagnostic-header contract we shipped this round.
|
||||||
|
|
||||||
|
Exit code: 0 == all green, 1 == any failure (caller escalates).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import json
|
||||||
|
|
||||||
|
BASE = "https://code.xinghanlab.com"
|
||||||
|
SESSION_UA = "heicode-smoke/1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def http(method: str, path: str, headers: dict | None = None, body: bytes | None = None):
|
||||||
|
"""Returns (status_code, headers_dict, body_text). Does NOT raise on 4xx/5xx."""
|
||||||
|
req = urllib.request.Request(BASE + path, method=method, data=body)
|
||||||
|
req.add_header("User-Agent", SESSION_UA)
|
||||||
|
for k, v in (headers or {}).items():
|
||||||
|
req.add_header(k, v)
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, dict(e.headers), e.read().decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
FAILS: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(label: str, ok: bool, detail: str = "") -> None:
|
||||||
|
mark = "OK " if ok else "FAIL"
|
||||||
|
print(f"[{mark}] {label}" + (f" -- {detail}" if detail else ""))
|
||||||
|
if not ok:
|
||||||
|
FAILS.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
print(f"Smoke testing {BASE} ...\n")
|
||||||
|
|
||||||
|
# 1. /api/status reachable + healthy
|
||||||
|
code, hdrs, body = http("GET", "/api/status")
|
||||||
|
ok = code == 200 and '"success":true' in body
|
||||||
|
check("/api/status returns 200 with success:true", ok,
|
||||||
|
f"HTTP {code}, body starts with {body[:80]!r}")
|
||||||
|
|
||||||
|
# 2. /api/devices without auth → 401 (route exists + protected)
|
||||||
|
code, hdrs, body = http("GET", "/api/devices/")
|
||||||
|
ok = code in (401, 403)
|
||||||
|
check("/api/devices unauthenticated → 401/403", ok, f"HTTP {code}")
|
||||||
|
|
||||||
|
# 3. /api/devices/pair without auth → 401
|
||||||
|
code, hdrs, body = http("POST", "/api/devices/pair",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=b'{"device_id":"x","public_key":"","fingerprint":""}')
|
||||||
|
ok = code in (401, 403)
|
||||||
|
check("/api/devices/pair unauthenticated → 401/403", ok, f"HTTP {code}")
|
||||||
|
|
||||||
|
# 4. /v1/messages with bogus V2 envelope → 401 + diagnostic headers.
|
||||||
|
# We construct a request that:
|
||||||
|
# - sets Content-Encoding: heicode-aead-v1 (forces V2 dispatcher)
|
||||||
|
# - sets junk for the 6 X-Heicode-* headers
|
||||||
|
# - sends a too-short body (12 bytes — fails AEAD nonce check)
|
||||||
|
# Expected: HTTP 401, X-Heicode-Auth-Error header present,
|
||||||
|
# X-Heicode-Server-Time header present and parseable as int.
|
||||||
|
bogus_headers = {
|
||||||
|
"Content-Encoding": "heicode-aead-v1",
|
||||||
|
"X-Heicode-Device-Id": "smoke-test-bogus",
|
||||||
|
"X-Heicode-Timestamp": str(int(time.time() * 1000)),
|
||||||
|
"X-Heicode-Nonce": "deadbeef" * 4,
|
||||||
|
"X-Heicode-Fingerprint": "0" * 64,
|
||||||
|
"X-Heicode-Eph-Pubkey": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=",
|
||||||
|
"X-Heicode-Signature": "BBBB",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
code, hdrs, body = http("POST", "/v1/messages",
|
||||||
|
headers=bogus_headers,
|
||||||
|
body=b"\x00" * 12)
|
||||||
|
|
||||||
|
ok_401 = code == 401
|
||||||
|
check("/v1/messages with bogus V2 envelope → 401", ok_401, f"HTTP {code}")
|
||||||
|
|
||||||
|
# Header lookup is case-insensitive in HTTP but our http() dict isn't.
|
||||||
|
# Build a lowercase view.
|
||||||
|
hdrs_lc = {k.lower(): v for k, v in hdrs.items()}
|
||||||
|
diag_code = hdrs_lc.get("x-heicode-auth-error", "")
|
||||||
|
diag_time = hdrs_lc.get("x-heicode-server-time", "")
|
||||||
|
check("V2 401 carries X-Heicode-Auth-Error header", bool(diag_code), f"value={diag_code!r}")
|
||||||
|
|
||||||
|
ok_time = False
|
||||||
|
try:
|
||||||
|
if diag_time and abs(int(diag_time) - int(time.time() * 1000)) < 60_000:
|
||||||
|
ok_time = True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
check("V2 401 carries X-Heicode-Server-Time within 60s of local clock",
|
||||||
|
ok_time, f"value={diag_time!r}")
|
||||||
|
|
||||||
|
# 5. Sanity: status data has live numbers (start_time freshly set if rebuild
|
||||||
|
# happened just now).
|
||||||
|
code, hdrs, body = http("GET", "/api/status")
|
||||||
|
try:
|
||||||
|
obj = json.loads(body)
|
||||||
|
st = obj.get("data", {}).get("start_time", 0)
|
||||||
|
age = int(time.time()) - st
|
||||||
|
print(f"\nManager start_time = {st} (age {age}s)")
|
||||||
|
check("Manager start_time looks recent (<30 min)", 0 <= age < 30 * 60,
|
||||||
|
f"age={age}s")
|
||||||
|
except Exception as e:
|
||||||
|
check("Manager status JSON parseable", False, repr(e))
|
||||||
|
|
||||||
|
if FAILS:
|
||||||
|
print(f"\n{len(FAILS)} smoke check(s) FAILED:")
|
||||||
|
for f in FAILS:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\nALL SMOKE CHECKS PASSED")
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Extended live smoke test against the production Manager. Exercises:
|
||||||
|
|
||||||
|
A. Routing surface
|
||||||
|
- /api/status → 200 healthy
|
||||||
|
- /api/devices/ → 401 (auth-protected, route mounted)
|
||||||
|
- /api/devices/pair → 401 without auth
|
||||||
|
- /api/devices/12345 PATCH/DELETE → 401 without auth
|
||||||
|
- Web SPA index → 200 (assets loadable)
|
||||||
|
- /v1/models with no auth → 401 (legacy v1 path still gated)
|
||||||
|
|
||||||
|
B. V2 diagnostic header contract
|
||||||
|
Exercises FIVE distinct V2 failure modes and asserts that each
|
||||||
|
returns:
|
||||||
|
- HTTP 401
|
||||||
|
- X-Heicode-Server-Time present, parseable, within 60s of local
|
||||||
|
- X-Heicode-Auth-Error = expected machine-readable code
|
||||||
|
This pins the whole error-code mapping we shipped in fix(devices).
|
||||||
|
|
||||||
|
C. Legacy compat
|
||||||
|
- V1 sk- request to /v1/models without bearer → 401
|
||||||
|
- V1 sk- request with bogus bearer "sk-xxx" → 401
|
||||||
|
Confirms the 30-day legacy compat path still runs (V2 dispatcher
|
||||||
|
isn't accidentally swallowing non-V2 traffic).
|
||||||
|
|
||||||
|
Exit code: 0 == all green, 1 == any failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
BASE = "https://code.xinghanlab.com"
|
||||||
|
UA = "heicode-smoke-extended/1.0"
|
||||||
|
|
||||||
|
FAILS = []
|
||||||
|
|
||||||
|
# Honour HTTPS_PROXY env (clash/wireguard/etc) — urllib doesn't pick
|
||||||
|
# it up automatically on Windows. Needed when the local machine has a
|
||||||
|
# DNS hijack returning sinkhole IPs for code.xinghanlab.com.
|
||||||
|
import os
|
||||||
|
_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
|
||||||
|
if _proxy:
|
||||||
|
proxy_handler = urllib.request.ProxyHandler({"https": _proxy, "http": _proxy})
|
||||||
|
_opener = urllib.request.build_opener(proxy_handler)
|
||||||
|
else:
|
||||||
|
_opener = urllib.request.build_opener()
|
||||||
|
|
||||||
|
|
||||||
|
def http(method, path, headers=None, body=None):
|
||||||
|
req = urllib.request.Request(BASE + path, method=method, data=body)
|
||||||
|
req.add_header("User-Agent", UA)
|
||||||
|
for k, v in (headers or {}).items():
|
||||||
|
req.add_header(k, v)
|
||||||
|
try:
|
||||||
|
r = _opener.open(req, timeout=15)
|
||||||
|
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read().decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, ok, detail=""):
|
||||||
|
print(f"[{'OK ' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else ""))
|
||||||
|
if not ok:
|
||||||
|
FAILS.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Exactly 32 bytes (0x41 * 32) — valid X25519 pubkey LENGTH (every
|
||||||
|
# 32-byte sequence is a valid X25519 point per spec). Using "wrong
|
||||||
|
# byte count" pubkey strings here would short-circuit the dispatcher
|
||||||
|
# at eph_pubkey_malformed before we can test the downstream code
|
||||||
|
# paths, so always start from a 32-byte default.
|
||||||
|
VALID_LEN_EPH_PUBKEY = base64.standard_b64encode(b"\x41" * 32).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def v2_headers(eph_pubkey_b64=VALID_LEN_EPH_PUBKEY,
|
||||||
|
timestamp_ms=None, nonce="dead" * 8):
|
||||||
|
return {
|
||||||
|
"Content-Encoding": "heicode-aead-v1",
|
||||||
|
"X-Heicode-Device-Id": "smoke-bogus",
|
||||||
|
"X-Heicode-Timestamp": str(timestamp_ms or int(time.time() * 1000)),
|
||||||
|
"X-Heicode-Nonce": nonce,
|
||||||
|
"X-Heicode-Fingerprint": "0" * 64,
|
||||||
|
"X-Heicode-Eph-Pubkey": eph_pubkey_b64,
|
||||||
|
"X-Heicode-Signature": "BBBBBBBB",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_v2_failure(label, headers, body, expected_code):
|
||||||
|
code, hdrs, _ = http("POST", "/v1/messages", headers=headers, body=body)
|
||||||
|
diag = hdrs.get("x-heicode-auth-error", "")
|
||||||
|
ts = hdrs.get("x-heicode-server-time", "")
|
||||||
|
ts_ok = False
|
||||||
|
try:
|
||||||
|
ts_ok = ts and abs(int(ts) - int(time.time() * 1000)) < 60_000
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
ok = code == 401 and diag == expected_code and ts_ok
|
||||||
|
check(label, ok, f"HTTP {code} code={diag!r} time={ts!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- A. Routing
|
||||||
|
print(f"\n=== A. Routing surface ({BASE}) ===\n")
|
||||||
|
|
||||||
|
code, _, body = http("GET", "/api/status")
|
||||||
|
check("/api/status returns 200 + success:true",
|
||||||
|
code == 200 and '"success":true' in body, f"HTTP {code}")
|
||||||
|
|
||||||
|
code, _, _ = http("GET", "/api/devices/")
|
||||||
|
check("/api/devices/ unauthenticated → 401", code == 401, f"HTTP {code}")
|
||||||
|
|
||||||
|
code, _, _ = http("POST", "/api/devices/pair",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=b'{"device_id":"x","public_key":"","fingerprint":""}')
|
||||||
|
check("/api/devices/pair unauthenticated → 401", code == 401, f"HTTP {code}")
|
||||||
|
|
||||||
|
code, _, _ = http("PATCH", "/api/devices/99999",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=b'{"device_name":"hax"}')
|
||||||
|
check("/api/devices/:id PATCH unauthenticated → 401", code == 401, f"HTTP {code}")
|
||||||
|
|
||||||
|
code, _, _ = http("DELETE", "/api/devices/99999")
|
||||||
|
check("/api/devices/:id DELETE unauthenticated → 401", code == 401, f"HTTP {code}")
|
||||||
|
|
||||||
|
code, _, body = http("GET", "/")
|
||||||
|
check("Web SPA index → 200", code == 200 and "<html" in body.lower(),
|
||||||
|
f"HTTP {code} len={len(body)}")
|
||||||
|
|
||||||
|
code, _, _ = http("GET", "/v1/models")
|
||||||
|
check("/v1/models without auth → 401", code == 401, f"HTTP {code}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- B. V2 diag codes
|
||||||
|
print("\n=== B. V2 diagnostic header contract ===\n")
|
||||||
|
|
||||||
|
# B1: malformed ephemeral pubkey (not base64 → eph_pubkey_malformed)
|
||||||
|
assert_v2_failure(
|
||||||
|
"V2 malformed eph_pubkey → eph_pubkey_malformed",
|
||||||
|
{**v2_headers(eph_pubkey_b64="not_base64_!!!"), },
|
||||||
|
b"\x00" * 12,
|
||||||
|
"eph_pubkey_malformed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# B2: pubkey base64-decodes but wrong length (not 32 bytes) → eph_pubkey_malformed
|
||||||
|
assert_v2_failure(
|
||||||
|
"V2 wrong-length eph_pubkey → eph_pubkey_malformed",
|
||||||
|
{**v2_headers(eph_pubkey_b64="QUE=")}, # decodes to 2 bytes
|
||||||
|
b"\x00" * 12,
|
||||||
|
"eph_pubkey_malformed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# B3: body too short (<28 bytes for nonce+tag) → body_too_short
|
||||||
|
assert_v2_failure(
|
||||||
|
"V2 body too short → body_too_short",
|
||||||
|
v2_headers(),
|
||||||
|
b"\x00" * 5, # too short
|
||||||
|
"body_too_short",
|
||||||
|
)
|
||||||
|
|
||||||
|
# B4: missing X-Heicode-Eph-Pubkey header entirely → eph_pubkey_missing
|
||||||
|
hdrs_no_eph = v2_headers()
|
||||||
|
hdrs_no_eph.pop("X-Heicode-Eph-Pubkey")
|
||||||
|
assert_v2_failure(
|
||||||
|
"V2 missing eph_pubkey header → eph_pubkey_missing",
|
||||||
|
hdrs_no_eph,
|
||||||
|
b"\x00" * 12,
|
||||||
|
"eph_pubkey_missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
# B5: valid-shape but unknown device + decoy ciphertext → after decrypt
|
||||||
|
# gets `body_decrypt_failed` (AEAD tag mismatch). This is the most
|
||||||
|
# common real-world failure mode for a tampered request.
|
||||||
|
assert_v2_failure(
|
||||||
|
"V2 valid-shape but undecryptable → body_decrypt_failed",
|
||||||
|
v2_headers(), # 32-byte AAAAAA... pubkey is valid X25519
|
||||||
|
b"\xAA" * 28, # 12 nonce + 16 tag, but ciphertext won't authenticate
|
||||||
|
"body_decrypt_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- C. Legacy compat
|
||||||
|
print("\n=== C. Legacy sk- bearer compat path ===\n")
|
||||||
|
|
||||||
|
# No Content-Encoding header → legacy dispatcher → ValidateUserToken
|
||||||
|
code, hdrs, _ = http("POST", "/v1/messages",
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer sk-bogus-token-not-real"},
|
||||||
|
body=b'{"model":"claude-haiku","messages":[]}')
|
||||||
|
# V2 diagnostic headers MUST NOT be present on legacy path responses.
|
||||||
|
diag = hdrs.get("x-heicode-auth-error", "")
|
||||||
|
check("Legacy sk- request → 401 (no Content-Encoding)", code == 401, f"HTTP {code}")
|
||||||
|
check("Legacy path does NOT leak V2 diagnostic headers", diag == "",
|
||||||
|
f"x-heicode-auth-error={diag!r}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- summary
|
||||||
|
if FAILS:
|
||||||
|
print(f"\n{len(FAILS)} smoke check(s) FAILED:")
|
||||||
|
for f in FAILS:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\nALL EXTENDED SMOKE CHECKS PASSED")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unstick a manager deploy where the container name collides with a
|
||||||
|
half-removed sibling. Removes everything named heicode* (except the
|
||||||
|
openbao auxiliary), then `compose up -d` to recreate cleanly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, paramiko, time
|
||||||
|
|
||||||
|
HOST = os.environ.get("MGR_HOST", "20.24.50.121")
|
||||||
|
USER = os.environ.get("MGR_USER", "heicode")
|
||||||
|
PASS = os.environ.get("MGR_PASS", "")
|
||||||
|
|
||||||
|
if not PASS:
|
||||||
|
print("MGR_PASS not set", file=sys.stderr); sys.exit(2)
|
||||||
|
|
||||||
|
def run(client, cmd, timeout=900):
|
||||||
|
print(f"\n$ {cmd}", flush=True)
|
||||||
|
_, stdout, _ = client.exec_command(cmd, timeout=timeout, get_pty=True)
|
||||||
|
for raw in iter(stdout.readline, ""):
|
||||||
|
if not raw: break
|
||||||
|
sys.stdout.write(raw.encode("ascii","replace").decode("ascii"))
|
||||||
|
sys.stdout.flush()
|
||||||
|
return stdout.channel.recv_exit_status()
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(hostname=HOST, port=22, username=USER, password=PASS,
|
||||||
|
look_for_keys=False, allow_agent=False, timeout=30)
|
||||||
|
|
||||||
|
# Find repo
|
||||||
|
run(client, "ls -d ~/heicode-mananger ~/heicode 2>/dev/null")
|
||||||
|
|
||||||
|
# Show all containers (running + stopped) related to heicode
|
||||||
|
run(client, "sudo docker ps -a --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}'")
|
||||||
|
|
||||||
|
# Remove any container that is exactly 'heicode' (the conflicting old one)
|
||||||
|
# AND any *_heicode temp.
|
||||||
|
run(client, "sudo docker rm -f heicode 2>/dev/null || true")
|
||||||
|
run(client, "sudo docker ps -aq --filter name=_heicode | xargs -r sudo docker rm -f")
|
||||||
|
|
||||||
|
run(client, "sudo docker ps -a --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}'")
|
||||||
|
|
||||||
|
# Re-up
|
||||||
|
repo = "/home/heicode/heicode-mananger"
|
||||||
|
# Try both common paths
|
||||||
|
run(client, f"test -d {repo} || ls -d ~/heicode-mananger 2>/dev/null")
|
||||||
|
run(client, f"cd ~/heicode-mananger/heicode && sudo docker compose -f docker-compose.azure-vm.yml up -d heicode 2>&1", timeout=600)
|
||||||
|
|
||||||
|
time.sleep(8)
|
||||||
|
run(client, "sudo docker ps --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.CreatedAt}}'")
|
||||||
|
run(client, "sudo docker logs --tail=20 heicode")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print("\nUNSTICK DONE")
|
||||||
Reference in New Issue
Block a user