# How To Use Luma

This is the operating manual for the first public version of Luma.

Luma keeps five concepts visible:

```text
node / region / exposure / egress / service
```

Luma Control runs on the manager node and owns login tokens, node registration, DNS sync, jobspec rendering, and Nomad deployment calls. The orchestrator underneath is HashiCorp Nomad. After `luma login`, `luma deploy` can be run from a client that does not have Docker, SSH access, Cloudflare credentials, or Nomad credentials. Tailscale is a control-plane network and a relay option for home services. Cloudflare is the DNS provider and optional tunnel provider. Egress Gateway is only for outbound traffic such as pulling images, installing dependencies, or running services that need external network access.

## 1. Install The CLI

```bash
curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/install-luma.sh | sh
~/.local/bin/luma preflight
```

This creates a private venv at `~/.local/share/luma/venv`, writes a `luma` command at `~/.local/bin/luma`, and adds `~/.local/bin` to your shell profile when needed. Use `~/.local/bin/luma` immediately, or open a new shell / run `exec $SHELL -l` before using the shorter `luma` command.

Install a specific tag:

```bash
curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/install-luma.sh | LUMA_INSTALL_REF=v0.1.365 sh
```

For local development from a checkout:

```bash
./scripts/install-luma.sh
. .venv/bin/activate
```

To uninstall the local CLI:

```bash
curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/uninstall-luma.sh | sh
```

The default uninstall keeps `~/.luma.config.json` and `~/.config/luma`. To remove local config and login contexts too:

```bash
curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/uninstall-luma.sh | sh -s -- --purge
```

This does not remove Docker, Nomad, Traefik, Luma Control, deployed services, or `/opt/luma` from a server.

If `python3` is missing, the installer prints the package command for macOS or Ubuntu/Debian. Local Docker is optional; it is only used to validate rendered jobspec files before deployment.

A checkout can keep secrets in `.env` (`cp .env.example .env`). A curl-installed CLI does not need that file: bootstrap and `luma node join` prompt for missing values. Luma also loads `.env` automatically when present. Shell exports win over `.env`, so CI or one-off commands can override local values.

## 2. Configure `luma.yaml`

Skip this file for the first manager. `luma bootstrap manager --domain luma.example.com` works from a curl-installed CLI: it prompts for missing secrets, infers the Cloudflare zone from the control domain when needed, and writes `/opt/luma/luma.yaml` on the manager.

Create a local `luma.yaml` later when you want to pin node public IPs, extra nodes, or image mirrors. It is the project config file Luma uses for those overrides:

```yaml
project: example

providers:
  dns:
    type: cloudflare
    zone: example.com
    zoneId: ""
    apiTokenEnv: CLOUDFLARE_API_TOKEN
    edgeTarget: 203.0.113.10
    recordType: A
    ttl: 1
    proxied: false

nodes:
  manager-1:
    host: manager-1
    publicIp: 203.0.113.10
    region: cn
    roles:
      - nomad-server
      - edge
      - egress

defaults:
  exposure: cn-edge
  registry: ghcr.io/liutianjie
  stackRoot: stacks
  routesRoot: routes
  egressNetwork: egress
  entrypoint: websecure
  certResolver: letsencrypt
  engine: nomad
  images:
    egressGateway: docker.1panel.live/metacubex/mihomo:latest
```

Run the command you actually need:

```bash
luma bootstrap manager --domain luma.example.com
```

If local values are missing, Luma asks for them first, writes `~/.luma.config.json`, then continues. On worker nodes, the same happens during `luma node join ...`. `.env` and exported environment variables still work for local overrides. If `CLOUDFLARE_API_TOKEN` is configured but `providers.dns` is missing, bootstrap and `luma update manager` infer the Cloudflare zone from the control domain and write the provider config before installing `/opt/luma/luma.yaml`. If no edge DNS target is configured, interactive bootstrap asks for `LUMA_DNS_EDGE_TARGET`; non-interactive update uses the configured edge node public IP or an existing `LUMA_DNS_EDGE_TARGET`.

The relevant keys are explained in [secrets.md](secrets.md). The common manager values are:

| Variable | Purpose |
| --- | --- |
| `CLOUDFLARE_API_TOKEN` | Cloudflare DNS token used to create/update control and service records. |
| `LUMA_DNS_EDGE_TARGET` | Public IP or DNS name that Cloudflare records should point to when no edge target is already configured. |
| `TRAEFIK_ACME_EMAIL` | Let's Encrypt account email used by Traefik for HTTPS certificates. |
| `EGRESS_SUBSCRIPTION_URL` | Optional proxy subscription URL for image-pull proxying and `proxy: true` services. |
| `TAILSCALE_AUTHKEY` | Optional auth key for private worker joins, home nodes, or tailscale-relay services. |
| `LUMA_SUDO_PASSWORD` | Optional fallback when sudo requires a password. |
| `LUMA_CONTROL_IMAGE` | Optional development/pinned control API image. |

Do not commit secrets.

## 3. Bootstrap The First Node

For a single public server that runs the Nomad server, Traefik, and egress:

```bash
luma bootstrap manager --domain luma.example.com
```

This does:

- installs Docker and Compose;
- installs Tailscale and logs in when `TAILSCALE_AUTHKEY` is set;
- installs and starts the Nomad server agent if needed;
- applies node `meta` (region / luma_node_name / ingress / egress);
- creates `/opt/luma/stacks`, `/opt/luma/routes`, `/opt/luma/control`, and `/opt/luma/egress-gateway`;
- deploys Traefik;
- deploys Luma Control;
- deploys egress when the profile has the `egress` role;
- configures UFW for SSH, 80, 443, the Nomad ports (4646/4647/4648) on the tailnet, and blocks inbound 7890.

Set `EGRESS_SUBSCRIPTION_URL` before running an egress profile when the manager needs a proxy to pull the configured control image. Mainland managers using the default GHCR control image should not use `--skip-egress`. Bootstrap prints live step logs with `[start]`, `[ok]`, and `[fail]` markers. If one step fails, either re-run bootstrap after fixing the issue or run the focused repair command for that layer.

If Tailscale login was skipped during bootstrap:

```bash
luma tailscale connect
```

If egress was skipped or needs repair:

```bash
luma egress setup
```

To intentionally skip egress during first bootstrap, only do this when the control image registry is directly reachable, or when `LUMA_CONTROL_IMAGE` / `defaults.images.lumaControl` points at a registry the manager can pull:

```bash
luma bootstrap manager --domain luma.example.com --skip-egress
```

The bootstrap output includes a management token, a node join token, and the dashboard URL. Open `https://luma.example.com/dashboard/`, paste the management token, then deploy **hello-world first install**. That smoke service does not need extra DNS, Tailscale, registry, or LAE.

Use the management token on client machines:

```bash
luma login https://luma.example.com --token <management-token>
luma context list
luma deploy templates/hello-world.yml
```

Use the node join token on additional servers:

```bash
luma node join https://luma.example.com --token <node-join-token> --region global --name global-sg-1
```

The manager records the node's region and Luma node `meta` automatically after the node joins. `--name` is the Luma node name used in service manifests. Luma writes it to the Nomad client `meta.luma_node_name` and uses it for pinned scheduling, so generic Docker hostnames such as OrbStack's `orbstack` do not collide. The Nomad node identity is a stable UUID, so a rejoin under the same name keeps pinned services valid.

For `--region home`, the node must be connected to Tailscale before it can reach a manager address on the tailnet. If the node is not connected yet, `luma node join` treats `TAILSCALE_AUTHKEY` as required and asks for it before registering the node. You can also run `luma tailscale connect` first to fill the key and connect Tailscale without attempting a Nomad enroll.

## 4. Connect Cloudflare

```bash
luma cloudflare connect --zone example.com
```

The command verifies the token, finds the zone, and writes `providers.dns.zoneId` back to `luma.yaml`.
Run this before `luma bootstrap manager` when possible. If you connect Cloudflare afterward, rerun manager bootstrap so `/opt/luma/luma.yaml` and `/opt/luma/control/control.json` are refreshed.

For `cn-edge` services, DNS defaults to the public IP of the configured edge node. A service can override this with:

```yaml
dns:
  target: 203.0.113.10
```

## 5. Repair Or Refresh Egress

```bash
luma egress setup
```

Bootstrap already runs this for `single-node` unless `--skip-egress` is used. Run it directly when egress was skipped, failed, or the subscription needs repair. It downloads the subscription, strips it into a minimal Mihomo config, writes it to `/opt/luma/egress-gateway/config.yaml`, deploys `egress_mihomo`, and configures Docker daemon proxy:

```text
HTTP_PROXY=http://127.0.0.1:7890
HTTPS_PROXY=http://127.0.0.1:7890
```

Service runtime proxy is opt-in per service. Declare `proxy: true` in the service manifest; do not hand-write the egress network or default proxy env unless you need to override them:

```yaml
name: ai-worker
image: ghcr.io/acme/ai-worker:1.0.0
region: cn
exposure: none
proxy: true
env:
  OPENAI_BASE_URL: https://api.openai.com/v1
```

Luma attaches the egress proxy to the service and injects `HTTP_PROXY=http://egress_mihomo:7890` plus `HTTPS_PROXY=http://egress_mihomo:7890` when those env vars are not already set. Scheduling still follows the service `region`.

Refresh subscription output later:

```bash
luma egress refresh
```

## 6. Create A Service

Interactive mode:

```bash
luma service new
```

Manual manifest:

```yaml
name: app
image: ghcr.io/me/app:latest
region: cn
exposure: cn-edge
domain: app.example.com
port: 3000
replicas: 2
```

To pin a service to one machine, add `node` with the Luma node name passed to `luma node join --name`:

```yaml
region: home
node: home-mac-mini
```

## 7. Deploy

Default deploy path:

```bash
luma deploy app.yaml
```

If the application already has a project `.env`, pass it as scoped deployment secrets:

```bash
luma deploy app.yaml --env .env
```

Luma imports only variables referenced by the manifest, stores them under the service `name`, and then resolves `${NAME}` placeholders during manager-side render. This lets different applications reuse common names such as `DATABASE_URL` without colliding.

This submits the manifest to the logged-in Luma Control endpoint. The manager renders generated files under `/opt/luma`, syncs DNS, creates or updates the Nomad job through the Nomad HTTP API, and probes the public route for `cn-edge` and `external-edge` services.

`luma deploy` prints client-side progress and each control-plane step. Luma validates generated Traefik file-provider routes, stages them outside the watched routes directory, then atomically publishes the final route file. A public route probe reports the HTTP status from `/`; an application-level `404` means the route reached the application but the app may not serve a root page, while Traefik's default `404 page not found` is treated as a missing router and a failed public route. When the probe reports the route unhealthy (Traefik router not found, or a transient `502`/`503`/`504`), Control recreates the service's allocation once and re-probes before failing the deploy. Luma gives single-service and Compose allocations 30 minutes for cold image acquisition and 40 minutes for rollout progress. The client waits up to 3000 seconds by default so it does not disconnect before that bounded rollout finishes. Override it when needed:

```bash
luma deploy app.yaml --timeout 3600
```

Repeated deploys are updates. The same service `name` maps to the same Nomad job (the job id is the service slug); running deploy again rewrites the generated jobspec and updates that job. Nomad keeps the previous version, so `luma rollback app` or the dashboard's Applications -> Versions action can return to it. This is a running job rollback, not a Git/manifest rewrite; use pinned image tags or digests for production. Changing `name` creates a different job.

Preview without side effects:

```bash
luma deploy app.yaml --dry-run
```

Submit to the control plane, render/write files on the manager, but skip DNS sync and the Nomad deploy step:

```bash
luma deploy app.yaml --skip-dns --skip-orchestrator
```

Remove a service or Compose application by its deployed name:

```bash
luma service remove app
```

Luma Control uses the manifest recorded during the last successful deploy. This deletes the Cloudflare DNS record for public services, deregisters and purges the Nomad job, and deletes generated manager files such as `/opt/luma/stacks/<region>/<service>/<service>.nomad.json`, `/opt/luma/stacks/compose/<name>/<name>.nomad.json`, and `routes/<service>.yml` for `tailscale-relay`. The same command removes single-service and Compose deployments. Preview first or keep DNS when needed:

```bash
luma service remove app --dry-run
luma service remove app --skip-dns
```

## 8. Exposure Modes

`cn-edge`:

```text
user -> Cloudflare DNS -> CN Traefik -> cn service
```

Use this for domestic public services.

`tailscale-relay`:

```text
user -> Cloudflare DNS -> CN Traefik -> Tailscale -> home service
```

Use this for low-frequency home services that should still share the same public domain experience.

`cloudflare-tunnel`:

```text
user -> Cloudflare -> cloudflared -> service
```

Use this for home services that should not depend on the CN edge.

`external-edge`:

```text
user -> Cloudflare DNS -> global edge -> global service
```

Use this for overseas services that need external network access and a public endpoint.

`none`:

No public entrypoint. Use it for workers and internal services.

## 9. Diagnose

```bash
luma doctor
```

Each failed check includes a concrete fix command or environment variable.

## 10. First Real Smoke Test

Use the reference node first:

```bash
luma doctor
luma bootstrap manager --domain luma.example.com
luma egress setup
luma deploy examples/public-cn-service.yaml
```

Then check:

```bash
nomad job status
curl -I https://whoami.example.com
```

Rotate any token or subscription URL that has been pasted into chat or logs before open-sourcing the repository.

## 11. Build And Deploy From Repository (可插拔)

默认的 `luma deploy` 只部署已经构建好的镜像。如果想直接从 GitHub/Gitea 仓库的源码构建并上线，用 `luma import`：它在集群里的**构建节点**上 clone 仓库、自动发现 `.luma.yml` 或 `luma.compose.yml` 这类部署文件、按 Dockerfile/Compose `build:` 构建镜像、推送到集群内自托管 registry，再走正常部署链路。这一整套是**可插拔的**——不用它，集群和现有部署不受任何影响；用它，只需下面这套一次性接入。

### 接入 SOP（已部署好 Luma 的前提下）

假设你已经走完上面 1–10 节，集群里 manager 正常、至少有一个 worker 节点、`luma login` 能用。接入「从 Git 仓库构建部署」分四步：

**Step 1 — 选一个构建节点并装好 buildx。** 构建在某个 Luma 节点上跑，需要 `docker buildx`（Linux 节点通常随 Docker 一起就有；跨架构构建还需要 `qemu`/`binfmt`）。节点 agent 会自动 advertise `docker-build` 能力，可以这样确认：

```bash
luma node list                 # 找到要用作构建节点的节点名，例如 build-1
```

如果该节点没有 buildx，先在节点上安装；装好后 agent 会自动带上 `docker-build` 能力。

**Step 2 — 起一个集群内 registry。** 构建出的镜像要有地方存，并让其它区域的节点能拉。一条命令搞定（部署 registry 服务 + 给非 manager 的就绪 Linux 节点配 `insecure-registries`）：

```bash
luma registry serve --node build-1
```

它会把 `registry:2` 部署到 `build-1`（默认 `5000` 端口、节点本地卷、仅 Tailscale 内网可达），并遍历非 manager 的就绪 Linux 节点配置 `insecure-registries`，让它们能经 Tailscale 内网从这个 registry 拉镜像。BuildKit 推送和目标节点拉取都走 `<build-1-tailscale-host>:5000`；不要再用 `localhost:5000` 作为 `pushHost`（在 BuildKit 容器里那是容器自己）。随后用 `luma build config` 把 `registryHost` 和 `pushHost` 都设成这个 Tailscale 端点。

从 `0.1.162` 起，CLI 会先完成 Docker daemon 配置、再创建 registry allocation；重复写入相同的 `insecure-registries` 也不会重启 Docker。这个顺序避免首次启用 registry 时由 Docker 重启打断刚创建的 Nomad CNI 网络。

可选 flag：

- `--port <n>`：registry 监听端口，默认 `5000`。
- `--storage-class <name>`：可选的遗留 storageClass；省略时使用节点本地 Docker volume。不要为新 registry 注册 NFS class。
- `--image <ref>`：registry 镜像，默认 `registry:2`。
- `--name <svc>`：服务名，默认 `luma-registry`。
- `--timeout <seconds>`：等待部署响应的秒数，默认 `1800`。

> manager 节点会被跳过：Control 跑在 manager 的容器里，重启它的 docker 会杀掉 Control 自己。如果 manager 也要跑从该 registry 拉取的服务，手动在 manager 的 `/etc/docker/daemon.json` 加 `insecure-registries` 并重启 docker。

**Step 3 —（私有仓库才需要）保存 Git provider token。** 公开仓库跳过这步。私有 GitHub/Gitea 仓库：

```bash
printf '%s' "$GITEA_TOKEN" | luma git-provider set gitea lin \
  --base-url https://gcode.example.com \
  --username lin \
  --token-stdin

luma git-provider repos gitea:lin
```

同一个 provider 可以保存多个账户，例如 `github:personal`、`github:work`、`gitea:lin`。Token 只写不回显，构建任务被 builder node-agent lease 时才注入 `git clone`，不写入部署文件或 agent task state。

**Step 4 — 在仓库里放 Luma 部署文件。** 单服务用普通 `.luma.yml` / `luma.yml` service manifest，并用 `build` 块代替 `image`：

```yaml
name: myapp
region: cn
exposure: cn-edge
domain: myapp.example.com
port: 8080
build:
  context: .
  dockerfile: Dockerfile
  platform: linux/amd64
```

Compose 仓库用 `luma.compose.yml` 指向标准 `docker-compose.yml`。Repository Import 会构建 Compose 里带 `build:` 的服务，推送到 builder registry，然后把这些服务改写成 `image:` 再部署：

```yaml
# luma.compose.yml
name: my-stack
compose: docker-compose.yml
region: cn
services:
  web:
    exposure: cn-edge
    domain: myapp.example.com
    port: 8080
```

支持的 Compose sidecar 文件名包括 `luma.compose.yml`、`.luma.compose.yml`、`*.luma.compose.yml`、`*.compose.luma.yml`、`docker-compose.luma.yml`。如果本地 Compose 还只有 `build:`、没有最终 `image:`，用 import 模式校验：

```bash
luma compose validate --import-mode luma.compose.yml
```

### 导入并部署

```bash
luma import https://github.com/acme/myapp --build-node build-1
```

GitHub 仓库也可以用短写：

```bash
luma import acme/myapp --build-node build-1
```

短写会展开成 `https://github.com/acme/myapp.git`。Gitea/self-hosted Git 用保存的 provider 账户或完整 clone URL。

使用保存的 Git provider 账户：

```bash
luma import --provider-id gitea:lin --repository acme/myapp --build-node build-1 --env .env
```

CLI 流式回传 clone → build → push → deploy 每一步。命令行可覆盖单服务 `.luma.yml`：

```bash
luma import https://github.com/acme/myapp \
  --ref release \
  --region cn --exposure cn-edge --domain myapp.example.com --port 8080
```

如果 Builder 排队或构建较慢，也可以直接用当前电脑的 Docker Buildx 构建：

```bash
cd myapp
luma build local . --env .env
```

这个命令从本地 Git `origin` 识别项目（没有 origin 时传
`--repo-url https://github.com/acme/myapp.git`），先向 Control 申请项目构建租约，
再把本地构建结果推到 Luma 内部 registry，最后沿用正常部署链路。镜像仍写入
`acme/myapp` 这一项目命名空间，不会因为改走本地构建而产生另一套项目。
本机需要能访问 `build.registryHost`；registry 开启鉴权时，先执行对应的
`docker login`。单服务与 Compose 都支持，也可以传 `--compose-sidecar`、
`--platform`、`--context`、`--dockerfile`。
如果本机已有带镜像加速或多架构能力的 Buildx builder，可以传
`--builder <名称>` 直接复用；本地拉取镜像和 Dockerfile 网络访问需要代理时，
可以传 `--proxy <地址>`，内部 registry 会自动保留在 `NO_PROXY`。

本地构建和 Builder 构建都会以实际部署目标为准选择容器架构：固定到
Mac/ARM 节点时构建 `linux/arm64`，目标区域同时存在 amd64 和 arm64 节点时
构建多架构镜像。显式 `--platform` 只能用于覆盖全部目标架构，不能把镜像
强制构建成与部署节点不兼容的架构。

Control 支持 `build-queue-v1` 时，新 CLI 自动使用同项目 FIFO 队列：远程
import / build retry 提交后排队；本地构建可以并行构建和上传，**上传完成后**
才将部署加入同一队列。顺序按服务端接受排队请求的先后计算，不按本地构建
开始时间计算。同一项目一次执行一个队列任务；不同项目可使用其它执行槽位，
但仍受已有 Builder 容量和运行时部署锁约束。前一个成功、失败或取消后，
后一个继续，不自动覆盖或取消旧任务。

CLI 显示任务 ID、队列位置和等待的任务。`--timeout` 到期或客户端退出只停止
等待，已接受的任务仍由服务端执行；用 `luma build logs <id>` 查状态，
`luma build cancel <id>` 取消尚未开始的任务。排队请求持久化，Control 重启后
保留；正在执行的任务明确标记中断，不自动重放可能已经生效的部署，重试前
先检查运行时。尚未上传完成的本地构建仍依赖调用者电脑。

环境变量按任务独立保存在私有 Control 状态中，不进入公开构建历史，任务结束
后清理队列载荷。旧 Control 仍按原来的活动构建互斥规则处理，需要同时升级
CLI 和 Control 才能使用队列。直接部署预构建镜像的 `deploy` / `compose deploy`
继续使用已有同步锁。Control 仍为本地上传分配唯一 tag，并校验镜像属于项目
预留的 registry 路径。

构建节点来自控制面声明的 builder 节点；通常不用传 `--build-node`，只有需要临时覆盖到另一个已声明 builder 时才传。单服务 import 还可用 `--context`（build 上下文目录，默认 `.`）、`--dockerfile`（默认 `Dockerfile`）、`--registry-host`（其它节点拉取用的 registry 主机，默认 `<build-node>:5000`）覆盖仓库里的 `build:` 字段。对 Compose import，`--region` 会覆盖 sidecar 的 region；`--exposure`、`--domain`、`--port` 是单服务覆盖项，会被忽略并打印 warning。Compose 的服务级路由请写在 `luma.compose.yml` 的 `services:` 里。`luma import` 默认等待 `3600` 秒的 build+deploy 响应，用 `--timeout <seconds>` 覆盖。

预声明 builder 节点和内部 registry 默认值，之后 import/build 就能省掉 `--build-node`：

```bash
luma build config --node build-1 --default-node build-1 \
  --registry-host <build-1-tailscale-host>:5000 \
  --push-host <build-1-tailscale-host>:5000
```

`--node` 可重复声明多个 builder；`--default-node` 是 `luma import` 缺省用的 builder；`--registry-host` 是 target node 拉镜像的地址，`--push-host` 是 BuildKit 推镜像的地址。两者都必须是 BuildKit 容器和所有目标节点可达的 Builder Tailscale endpoint；不要使用已移除且在 BuildKit namespace 内含义错误的 `localhost:5000`。不带参数运行 `luma build config` 只打印当前配置和各 builder 的就绪/能力表。

构建历史和失败日志用 CLI 查看：`luma build list`（打印 ID/状态/节点/provider/仓库/ref）、`luma build logs <id>`（某次构建的分步日志）。修好凭据或配置后 `luma build retry <id>` 重跑整条 build+deploy；`retry` 也接受 `--env .env` 重新提供 scoped secrets、`--timeout`（默认 3600）。

dashboard 的「创建应用」页顶部也有「仓库导入」入口：选择 Git provider、账户、仓库和 ref；或手填 URL。进度实时显示。

> CN 节点的 `git clone` 会自动走 manager 的 egress 网关（`http://<manager-host>:7890`），和镜像拉取、节点 join 用的是同一个出口；`global` 节点直连不走代理。代理出口由控制面的 egress 配置决定，无需在 `luma import` 上单独指定。

### 升级已部署的应用

**升级 = 改完代码、推到 GitHub，再跑一次同样的 `luma import`。**

```bash
luma import https://github.com/acme/myapp --build-node build-1
```

原理：每次构建按 git commit 打 tag（`<registry>:5000/acme/myapp:<git-sha>`），注入 manifest 的是这个不可变的 sha 标签；而 Nomad job id 来自 `.luma.yml` 的 `name`。所以同名 + 新 SHA = 对同一个 Nomad job 做滚动更新，旧版本自动保留。这和普通 `luma deploy` 的「同名即更新」是同一条链路。

升级特定分支/Tag：

```bash
luma import https://github.com/acme/myapp --build-node build-1 --ref v2.1.0
```

要点：

- 镜像钉死在 git SHA，不是 `:latest`，所以两次构建之间即使 registry 里 `:latest` 变了，已部署的旧版本也不会漂移。
- `name` 不变才是升级；改了 `.luma.yml` 里的 `name` 会创建一个新应用，而不是升级旧的。
- 改了 `domain`/`region`/`port` 等也通过重跑 import 生效（或用对应 CLI flag 覆盖）。

### 回滚

因为每个历史版本的 jobspec 钉的是各自的 `:<git-sha>`，registry 默认不回收旧镜像，所以回滚拉得回原镜像字节：

```bash
luma history myapp                 # 看版本列表
luma rollback myapp                # 回到上一个版本
luma rollback myapp --to-version <N>
```

dashboard 的 Applications → Versions 也能做同样的回滚。注意这是 Nomad job 版本回退（拉回那个版本钉的镜像），不会改 Git、不会回滚数据库迁移或卷数据。

### 跨架构注意

构建节点是 arm64（比如 Mac mini）而目标运行节点是 amd64 时，必须让构建产出 `linux/amd64`。`.luma.yml` 的 `build.platform` 默认就是 `linux/amd64`，但构建节点要装好 `qemu`/`binfmt` 才能跨架构构建。

### 取消接入

不想再用时，删掉 registry 服务即可（`luma service remove luma-registry`），已经在跑的服务不受影响。`insecure-registries` 配置留在各节点的 docker daemon 里是无害的。

## 12. 通过 Dashboard 升级 Luma

日常升级不需要 SSH manager，也不需要在每台节点运行命令。使用管理 Token 登录 `https://<control-domain>/dashboard/fleet`，在「升级中心」完成整条流程：

1. 填写不可变 release tag（例如 `v0.1.175`）。标准 tag 会自动建议同 tag 的 Control 镜像；自定义分支或 commit 需要明确填写已发布镜像。
2. 点击「检查全部公网路由」保存升级前基线。HTTP 成功、重定向和鉴权拒绝都能证明路由已发布；404、网关错误和连接失败会明确标红，但不会悄悄阻断管理员操作。
3. 第一次点击「升级 Control」会展示影响和基线结果，第二次确认后，Builder 先通过受管出网代理把外部 Control 镜像缓存到内部 registry 并校验 digest。该任务可恢复、有明确进度，避免 manager 的 Docker daemon 在替换窗口直接依赖 GHCR。
4. 内网镜像就绪后才启动 Control 更新。更新运行在独立 systemd 单元中，不会因 node agent 自身重启而被中断。
5. Control 替换期间页面自动重连。完成后自动重新检查全部公网路由，并展示有界日志；无需手动重启应用。
6. 点击「更新未对齐节点」。操作会只选择版本落后的非 manager 节点，并持久化逐节点状态。安装完成后，节点必须以目标版本重新心跳，才会显示成功。
7. 失败或中断的镜像准备、Control 或节点任务都会保留原因，可在同一页面重试。关闭页面、刷新浏览器或 Control 重启都不会丢失操作结果。

首次接入一个早于托管更新能力的历史 agent 时，Dashboard 会明确显示缺失的 capability；只需对该历史节点做一次 CLI 更新。之后的发布均可在升级中心完成。

## 13. 应用级可观测（luma-observe）

Dashboard 里的 CPU/内存是节点样本，不是应用是否可用。HTTP 5xx、延迟、Nomad 失败/重启和节点水位走独立的 [`observe/`](../observe/) 栈，和 Traefik/Control 同机、只绑 loopback。部署该栈后 Control 会把它钉到 manager、把 Grafana 挂到控制面域名的 `/grafana`，告警规则里会出现 p95 / 5xx / 失败 alloc 预设。详见 [Observability](observability.md)。
