# cube

Fam 的小 app 平台。

## 是什么

`cube` 是一个跑在 **`famzheng.me` 节点**（hostname `famzheng.com`，单节点 k3s + traefik + gitea，公网 IP `178.104.186.206`）上的小 app 平台，专门收纳 Fam 自己写的一堆小工具/玩具 web app。底层硬件、k3s、Gitea、act_runner 等基础设施详见 `~/.claude/memory/infra.md`。

主要任务：把目前散落在 `oci.euphon.net`（Oracle Cloud ARM VM）上**值得留下**的 Fam 个人小 app 迁过来。oci 主机本身不退役，留给 Hera 同学继续用。

设计目标：反 Karpathy "web app 像拼宜家家具" 的困境 —— 全部自有基础设施，**零跨 dashboard 配置**，新 app 上线压缩到 5 分钟内。

---

## 平台约定

> 这是 cube 上所有 app 的"宪法"，所有 app 必须遵守，破例必须改这份文档并经 Fam 拍板。

### 仓库结构：monorepo + cargo workspace

整个 cube 是**一个 git 仓库** `fam/cube`，cargo workspace 模式。

```
cube/
├── Cargo.toml             # workspace manifest
├── crates/
│   └── cube-core/         # 共享样板：base router / healthz / tracing / shutdown / config
├── apps/
│   ├── cube/              # app #0 = cube.famzheng.me 入口门户
│   │   ├── Cargo.toml     # path 依赖 cube-core
│   │   ├── src/main.rs
│   │   ├── frontend/      # vite + vue 3 + ts
│   │   ├── Dockerfile
│   │   └── k8s/           # deployment / service / ingress
│   ├── portfolio/
│   ├── repo-vis/
│   └── ...
├── doc/
└── .gitea/workflows/      # CI；按 path 触发，per-app build/deploy
```

`cube-core` 是 **path 依赖**（`cube-core = { path = "../../crates/cube-core" }`），不发布、不打 tag、不走 git 依赖。改 `cube-core` 时所有 app 一起 rebuild，monorepo 的整个意义就在于"一次重构，全员同步"。

### 部署目标

单一目标：famzheng.me 节点的 k3s（`kubectl context default`），不双轨。
- 每 app 一个 k8s namespace，ns 名 = `cube-<app>`（如 `cube-cube` / `cube-portfolio`），便于跟其他 ns 隔离 + 一眼可见归属
- traefik ingress + 通配符 LE 证书自动签

### 域名

- `<app>.famzheng.me`，wildcard A 记录已配，**零 DNS 操作**
- 不嵌 `cube` 子域（冲突检查 = 起新 ingress 时 traefik 自然报错，不需要额外心智）
- 旧域名（如 `portfolio.oci.euphon.net`）让 oci ingress 兜底 308 redirect 到新地址，过渡期后下掉

### 后端：Rust + Axum

每个 app 是 workspace 里的一个 bin crate，单 axum 服务。

`cube-core` 提供：
- `/healthz` router（200 = ok）
- `ServeDir` 静态前端 fallback 到 `index.html`（SPA 路由兼容）
- `tracing` 配 JSON stdout
- SIGTERM graceful shutdown
- env → struct 配置加载

业务 app 只写 `/api/*` 路由 + handler：

```rust
let app = cube_core::base("dist")
    .nest("/api", api_routes());
cube_core::serve(app).await
```

### 前端：Vite + Vue 3

- 默认栈：**Vite + Vue 3 + TypeScript + Pinia + Vue Router**
- 选 Vue 而不是 Svelte 的原因：AI（Claude / GPT）写 Vue 3 `<script setup>` + Composition API 训练语料密度大、bug 少；Svelte 5 runes 出来后 AI 经常混 4/5 语法
- build 输出到 `frontend/dist/`，axum 用 `ServeDir` 同进程同容器同域名 serve
- **不搞前后端分离部署**、不搞独立前端域名、不搞独立 CI

### 构建：host musl + scratch 容器

host 上直接 cargo build，不在容器里跑 cargo。每个 app 的 build 命令固定：

```bash
cargo build --release --target x86_64-unknown-linux-musl -p <app>
(cd apps/<app>/frontend && npm ci && npm run build)
```

Dockerfile（每个 app 自带一份，结构一致）：

```dockerfile
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/<app> /app
COPY apps/<app>/frontend/dist /dist
ENTRYPOINT ["/app"]
```

- 镜像 < 20MB
- runner 上不跑 docker buildx 套娃，build 速度爆表
- prereq（首次设置 host）：`apt install musl-tools` + `rustup target add x86_64-unknown-linux-musl`

### Container Registry

- 用 **gitea 自带** Container Registry（Packages 功能，gitea 1.20+ 自带）
- **坑**：gitea 挂在 `/gitea/` 子路径下，docker daemon 默认拼 `https://famzheng.me/v2/...` 会 404
- 方案：加一条 ingress `registry.famzheng.me` 反代 `/v2/*` → `gitea-svc:3000/v2/*`，复用 gitea token 鉴权
- 镜像命名：`registry.famzheng.me/mochi/<app>:<sha>`
- ingress 已落地：`apps/cube/k8s/_registry-ingress.yaml`（用 `mochi` bot 账户的 token push）

### CI/CD

走 gitea Actions，复用现有 instance-level act_runner（fam 用户，host shell 模式，labels `*:host`，**新 repo 不用注册 runner**）。

monorepo 每 app 一份 workflow `.gitea/workflows/deploy-<app>.yml`，按 `paths` 触发（只改 `apps/<app>/**` 和 `crates/cube-core/**` 才跑）。固定 5 步：

1. `cargo build --release --target x86_64-unknown-linux-musl -p <app>`
2. `(cd apps/<app>/frontend && npm ci && npm run build)`
3. `docker build -f apps/<app>/Dockerfile -t registry.famzheng.me/mochi/<app>:$GITHUB_SHA .`
4. `docker push registry.famzheng.me/mochi/<app>:$GITHUB_SHA`
5. `kubectl -n cube-<app> set image deploy/<app> <app>=registry.famzheng.me/mochi/<app>:$GITHUB_SHA`

host shell PATH 注意：workflow 第一行 `export PATH="$HOME/.cargo/bin:$PATH"`（fam 的 rustup 装在 `~/.cargo`）。

> ⚠️ workflow `runs-on:` 只能写 label 名（`ubuntu-latest`），**不要带 `:host` 后缀** —— act_runner 注册到 gitea 时只 declare label name，写后缀会一直 queued。

**不做 PR 预览环境** —— 个人小 app 不需要，徒增复杂度。

### 通用约定（每个 app 都要遵守）

- 暴露 `/healthz`（200 = ok），k8s liveness/readiness 共用同一个 endpoint
- 日志统一 stdout（`cube-core` 的 tracing 配 JSON），让 k3s 收
- 配置走 env var + k8s Secret，**禁止** config.yaml 文件挂载（partiverse 那个"空目录顶替 config 文件"的坑别再踩）
- 数据持久化只走 PVC + 每天 minio backup CronJob（`cube-core` 提供 base 模板）
- 镜像 tag 用 git SHA，不用 `latest`
- **前端视图状态走 URL**（path + query params），保证任意视图（筛选、分页、tab、详情）都能被 bookmark / share。可收藏的 state 必须在 URL 里，不准只活在 Pinia / `ref()` 里。Vue Router 的 `useRoute()` + `router.replace({ query: ... })` 是默认搭配。

---

## 当前状态

- **app #0 `cube` 已上线**（2026-05-04）：cube-core scaffold 完成，入口门户跑在 `cube.famzheng.me`，CI 全链路（cargo musl → npm build → docker push → kubectl rollout）通了。
- **app #1 `simpleasm` 已上线**（2026-05-04）：从 oci 迁过来，跑在 `asm.famzheng.me`。FastAPI 重写为 axum + rusqlite，前端 Vue3 原样搬。oci 端旧域名 308 永久 redirect。
- 迁移源端清单 + 已迁完成的见 [`doc/todo.md`](doc/todo.md)。

## 迁移名单（截至 2026-05-04）

- `portfolio`（host systemd `portfolio.service`，uvicorn `:8890`，**不在 k3s 里**；迁过来要重写成 Rust + axum 还是直接装 python 运行时？待定）
- `repo-vis`
- `simpleasm`
- `guitar`
- `pyroblem`（详情待补）

## 相关

- 宿主节点：`famzheng.me` / hostname `famzheng.com` —— 详见 `~/.claude/memory/infra.md`
- 迁移源：`oci.euphon.net`（ARM Ubuntu 22.04 + k3s）
