# /usr/local/bin 下的"项目脚本入口"必须用 symlink，不能 plain cp

**Date**: 2026-05-19
**Module**: internal-app-platform / deploy pipeline
**Related**: [`2026-05-19-deploy-script-env-via-flag-not-process-env.md`](2026-05-19-deploy-script-env-via-flag-not-process-env.md)（前置 PR #451 修的"半边"）

## 症状

PR #451（commit 8c6df629，给 deploy-container.sh 加 `--apps-domain` flag）合到 develop、
deploy-test workflow 走完一切绿、backend 重启进了新版 dist 调起 `--apps-domain ...` flag。
员工 push itadmin-ffoa 触发 webhook：

```
2026-05-19 15:29:20 error [ContainerHostService] deploy script failed: unknown arg: --apps-domain
```

**比 fix 前还糟**：fix 前是 Caddy 写错域名 → 空白页（无声坏）；fix 后是脚本直接 exit 64 →
DB status FAILED + 容器没动 → 同样空白页 + 报错入库。

## 根因

backend `container-host.service.ts:65` 调的 `deployBinary` 默认是
`/usr/local/bin/ffoa-deploy`，**不是** git 仓库里的
`/srv/apps/ffworkspace-test/scripts/internal-app-platform/deploy-container.sh`。

| 路径 | mtime | 含 `--apps-domain` flag |
|---|---|---|
| repo: `/srv/apps/.../deploy-container.sh` | 2026-05-19 15:22 | ✓ |
| 入口: `/usr/local/bin/ffoa-deploy` | **2026-05-16 17:04** | ✗ |

`/usr/local/bin/ffoa-deploy` 是一个**普通 cp**，5/16 某次手工 setup 时拷过去的，
之后 deploy-test workflow 每次只 `git fetch + switch` 仓库，**从不刷这个 binary**。
git 仓库脚本和入口 binary **物理脱钩 3 天**。

setup-test-server.sh 注释里写"setup 默认装到 /usr/local/bin/ffoa-deploy"，但
**实际没装**——历史误导性注释。

## 修复（三件套）

1. **setup-test-server.sh**：新增 section 8，装 ffoa-deploy 为 `ln -sfn` 指向仓库脚本——
   一次性 bootstrap 后即"git pull 自动生效"。
2. **deploy-test.yml**：每次部署在 SSH block 跑 `sudo ln -sfn .../deploy-container.sh /usr/local/bin/ffoa-deploy`
   ——自愈，防有人偶然手工 cp 覆盖回去。
3. **container-host.service.ts:64 注释更正**：明确说"symlink，禁止 plain cp"。

## 通用原则：`/usr/local/bin/` 下的项目脚本入口必须是 symlink

只要满足以下条件，就用 symlink 不用 cp：

- 入口装在 OS 路径（`/usr/local/bin/`、`/etc/systemd/system/`）
- 源文件**也由 git 管**（在 `/srv/apps/<repo>/` 内）
- 期望"git pull 后立即生效"

cp 把"哪份是真"这个语义切成两份：

| | cp | symlink |
|---|---|---|
| git pull 后生效 | ❌ 需要再 cp | ✓ 自动 |
| 谁是真源 | 模糊（两份都活着）| 明确（仓库脚本）|
| 排查时 | grep 一处找 stale | mtime 一眼漂移 |
| 偶然手改 binary | 神不知鬼不觉 | symlink 关系明显，改不了 |

systemd unit file 是反例——`systemctl` daemon-reload 后必须重读，**必须** cp 到
`/etc/systemd/system/`，不能 symlink 到 git 仓库（systemd 不跟 symlink 解析 target 的 mtime）。
判别准则：**消费方是否懒加载？** 懒加载（每次 exec 才读）= symlink 安全；预加载（启动时读
入内存）= 必须 cp。

## 元规则：deploy pipeline 必须覆盖**所有**运行时依赖

deploy-test workflow 之前只更新了：
- ✓ backend 源码 / dist
- ✓ frontend 构建
- ✓ DB schema
- ✗ **`/usr/local/bin/ffoa-deploy`**（外部于 `/srv/apps/<repo>` 的 binary）
- ✗ systemd unit files（万一改了也漂移）
- ✗ caddy/nginx 配置（同理）

**任何"装到仓库目录之外的文件"都是 deploy pipeline 必须显式接管的依赖**。git pull 只覆
盖仓库目录，所以靠 git 自动同步是错觉。每个外部落点要么用 symlink（自愈）、要么在 deploy
workflow 加 cp 步骤（显式）。**不允许"setup 时装一次后就不管"**——任何 fresh server 都
要能从 develop 直接 deploy 起来，而不依赖"先跑一次 setup"的隐式前置。

## 待办（同类 risk 未消，跟踪）

- **UAT 服务器（43.153.69.73）/ Prod（43.130.6.44）**：同样的 stale binary 问题大概率
  存在。本 PR 只修 test workflow，UAT/Prod 的 deploy workflow 也要加同样的 symlink 自愈步骤。
- **AIxC Workspace UAT/Prod**：同代码仓库不同部署目标，同问题概率高。
- 全仓 grep `/usr/local/bin/ffoa-` 看还有没有别的同类 binary 入口（如 sweep-expired-apps 用的是 `install -m 0755 cp` 模式，systemd timer 消费方，性质不同——但值得复核一遍）。

## 反例：跨进程 + 跨 mtime 双重隐式依赖

把这次跟 PR #451 串起来看：

- PR #447（webhook 状态推进）→ 修了"事件流 ≠ 状态机"的隐式跳级
- PR #451（`--apps-domain` flag）→ 修了"跨进程边界 = 必须显式契约"
- **本 PR**（symlink ffoa-deploy）→ 修了"跨 mtime 边界 = 必须显式同步"

三个修复同一类元 bug：**任何"看起来自动"的同步关系，背后都得有显式机制**。
NestJS ConfigService 不写 process.env、`/usr/local/bin` 不跟 git 走、webhook event ≠ DB
status，全是"开发者心智模型默认 X 同步、运行时实际不同步"的经典坑。
