docs+chore: 部署脚本入库、README 补零停机部署与部署物料清单
仓库此前缺少部署脚本,只能靠临时文件操作,新人无法复现。补齐: - deploy/zd.sh:零停机部署(新容器先在对侧端口验证到底,含 /search 预热 SearXNG;再同 inode 覆写门户上游 + nginx -t + reload;切后哨兵"不得为 502"; 失败自动回滚,旧容器保留为回滚点)。可用环境变量覆盖主机/路径/镜像。 - deploy/verify-ui.js:部署后浏览器验收(渲染开销、后台窄屏、收边两段式、 被拒内嵌弹层、分组增删改与「历史访问」保护)。任一运行时异常即判失败。 README 新增: - 「零停机部署」含**单文件 bind mount 不能用 sed -i** 的坑(换 inode → 容器读到 旧值 → 全站 502,本项目真实踩过)与正确顺序(同 inode 覆写 → 断言容器看到 → nginx -t → reload) - 「部署需要的全部自定义文件」清单表 - 「管理后台」补分组自由增删、历史分组保护、完整 API 表 - 「工作空间视图」补可内嵌性三态探测(转发 Cookie、WAF 403 误判)、被拒弹层、 悬浮控件收边与"第一次交互只滑出"
This commit is contained in:
127
README.md
127
README.md
@ -210,7 +210,40 @@ conf.yml 里写的是本地文件名,与手工图标走同一套解析。
|
||||
|
||||
访问 `http://<your-ip>:3003/admin/` 可视化管理导航项。
|
||||
|
||||
API(需 `NAVI_ADMIN_TOKEN`):
|
||||
页面分卡片:站点信息(标题/副标题/顶部链接/备案号)、**导航分组**、添加服务、
|
||||
历史访问、外观(壁纸)、接口令牌。窄屏(手机)已适配:字段竖排、分组行可换行、
|
||||
输入框整行占满、字号 ≥16px 防 iOS 聚焦缩放。
|
||||
|
||||
### 导航分组可以自由增删
|
||||
|
||||
分组就是导航页上的栏目,初始为空(`conf.example.yml` 的 `sections: []`),
|
||||
按需创建即可。要点:
|
||||
|
||||
- 删除**非空**分组需要二次确认(接口侧要求 `?force=1`),避免误点连带删掉一组服务
|
||||
- **「历史访问」分组由 history-server 依据浏览记录自动维护**:改名/删除/手工新建
|
||||
都会被拒绝(409)并在界面上锁住——否则下次同步就把它覆写回来
|
||||
- 若配置里完全没有该分组,history-server 会补建一个(但**完全空配置时不补**,
|
||||
保持"初始无分组")
|
||||
|
||||
### API(需 `NAVI_ADMIN_TOKEN`)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/admin/api/sections` | GET | 列出分组与服务 |
|
||||
| `/admin/api/section` | POST | 新建分组 `{name,icon}`;重名/空名/占用「历史访问」→ 4xx |
|
||||
| `/admin/api/section` | DELETE | 删除分组 `{index}`;非空需 `?force=1` |
|
||||
| `/admin/api/section/update` | POST | 改名/换图标 `{index,name,icon}` |
|
||||
| `/admin/api/item` | POST | 向分组添加服务 |
|
||||
| `/admin/api/item/delete` | DELETE | 删除服务 `{sectionIndex,itemIndex}` |
|
||||
| `/admin/api/pageinfo` | GET/POST | 站点信息与备案号 |
|
||||
| `/admin/api/icon` | POST | 上传图标(`.ico` 前端转 PNG) |
|
||||
| `/admin/api/wallpaper` | GET/POST | 壁纸与遮罩强度 |
|
||||
| `/admin/api/history` | GET/DELETE | 浏览历史列表 / 清空全部 |
|
||||
| `/admin/api/history/item` | DELETE | 删除单条历史 `?url=` |
|
||||
| `/admin/api/frameable` | GET | 目标地址能否被 iframe 内嵌(三态,见下) |
|
||||
| `/admin/api/sync-info` | GET | 插件安装页用的同步令牌 |
|
||||
|
||||
```bash
|
||||
|
||||
```bash
|
||||
# 查看
|
||||
@ -249,6 +282,76 @@ curl -H "Authorization: Bearer <token>" -X DELETE -H 'Content-Type: application/
|
||||
}
|
||||
```
|
||||
|
||||
## 零停机部署
|
||||
|
||||
不要直接 `docker stop/rename/run`:那样有十几秒完全不可用,而且新容器里
|
||||
**nginx 先就绪、SearXNG 还在启动**,这期间 `/search` 会是 502(本项目实际发生过)。
|
||||
|
||||
用仓库里的脚本,它会先在对侧端口把新容器验证到底、再瞬时切换上游:
|
||||
|
||||
```bash
|
||||
deploy/zd.sh v29 # 版本号只用于镜像 tag
|
||||
```
|
||||
|
||||
流程(任一步失败自动回滚):
|
||||
|
||||
1. 本地构建镜像 → `docker save` → scp
|
||||
2. 远端在**另一侧端口**(3003/3004 自动对调)起 `navi-next`
|
||||
3. 在该端口逐个验证 `/conf.yml`、`/admin`、`/navi.js`、**`/search?q=linux`**
|
||||
(后者既确认 SearXNG 可用,也把它预热,避免用户撞上冷启动 502)
|
||||
4. 改门户 nginx 上游那一行 → 容器侧确认看到新值 → `nginx -t` → `nginx -s reload`
|
||||
5. **切后哨兵**:经门户访问不得是 502/503/504(未登录正常是 302)
|
||||
6. 停旧容器并保留为回滚点 `navi-prev-<时间戳>`,新容器改名为 `navi`
|
||||
|
||||
可用环境变量:`NAVI_HOST`、`NAVI_SSH_PASS`、`NAVI_UPSTREAM_HOST`、`NAVI_PORTAL_CONF`、
|
||||
`NAVI_DATA_DIR`、`NAVI_BASE_IMAGE`。
|
||||
|
||||
### 一个必须知道的坑:单文件 bind mount 不能用 `sed -i`
|
||||
|
||||
门户 nginx 配置文件是以**单文件 bind mount**(且只读)挂进 `portal-nginx` 容器的。
|
||||
`sed -i` / `mv` 会写临时文件再 rename,**换了 inode**,而容器在启动时就绑定了当时那个
|
||||
inode —— 于是宿主机文件改好了,容器里看到的还是旧的,`nginx -s reload` 读的也是旧的。
|
||||
一旦上游被切走而新端口无人应答,就是**全站 502**。
|
||||
|
||||
所以:**同 inode 覆写**(`cat new > file`),改完先用
|
||||
`docker exec portal-nginx grep -q "<新端口>" /etc/nginx/nginx.conf` **断言容器真的看到了**,
|
||||
看不到就 `docker restart portal-nginx`(唯一出路),然后再 `nginx -t` + `reload`。
|
||||
`deploy/zd.sh` 已按这个顺序实现。
|
||||
|
||||
### 部署后验收
|
||||
|
||||
```bash
|
||||
# 起一个 headless chromium 指向 CDP 端口,然后:
|
||||
PORT=<cdp端口> CK=<portal_session> VPS=<对外IP> node deploy/verify-ui.js
|
||||
```
|
||||
|
||||
覆盖:渲染开销(`backdrop-filter` 元素数)、后台窄屏是否横向溢出、工作空间收边与
|
||||
"第一次交互只滑出"、不支持内嵌应用的弹层、分组增删改与「历史访问」保护。
|
||||
**任何运行时异常都会判失败**(promise 里抛出的错误很容易被忽略,本项目因此漏掉过
|
||||
一个 `ReferenceError` 五个版本)。
|
||||
|
||||
## 部署需要的全部自定义文件
|
||||
|
||||
仓库即完整部署物料,没有额外依赖仓库外的文件:
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `Dockerfile` | 镜像定义(构建期用 `web/patch-index.py` 往 Dashy 的 index.html 注入资源) |
|
||||
| `nginx.conf` | 容器内 nginx:Dashy 静态 + SearXNG + history/admin API 的统一切入口 |
|
||||
| `supervisord.conf` | 容器内三服务(nginx / SearXNG / 两个 Go 二进制)的进程管理 |
|
||||
| `searxng-settings.yml` | SearXNG 引擎与出网代理由此注入(可被数据卷同名文件覆盖) |
|
||||
| `conf.example.yml` | 首次启动生成 `conf.yml` 的模板(**sections 为空**,分组由后台创建) |
|
||||
| `admin/index.html` | 管理后台单页(站点信息 / 分组 / 服务 / 图标 / 壁纸 / 历史) |
|
||||
| `admin-server/` | Go:站点信息、分组与服务增删改、图标与壁纸上传、历史转发、可内嵌性探测 |
|
||||
| `history-server/` | Go:浏览历史接收与排序、favicon 抓取、按访问频次重建 Dashy 分组 |
|
||||
| `web/navi.js` / `web/navi.css` | 门户表现层:搜索球、视图切换、管理入口、收边、工作空间内嵌、页脚 |
|
||||
| `web/patch-index.py` | 幂等地把 `navi.css` / `navi.js` 注入 Dashy 的 index.html |
|
||||
| `web/extension/install.html` | 插件安装引导页(含同步令牌展示) |
|
||||
| `extension/chrome/` `extension/firefox/` | 浏览器扩展(MV3 / MV2) |
|
||||
| `deploy/zd.sh` | 零停机部署脚本 |
|
||||
| `deploy/verify-ui.js` | 部署后浏览器验收脚本 |
|
||||
| `docker-compose.yml` / `.env.example` | 单机 compose 启动与变量样例 |
|
||||
|
||||
## 生产部署:外层统一认证反向代理
|
||||
|
||||
> 内网门户通常不直接暴露 3003 端口,而是放在一个**带统一登录认证的反向代理**后面,
|
||||
@ -532,6 +635,28 @@ data/
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## 工作空间视图
|
||||
|
||||
点左侧分组 → 右侧**在 iframe 里打开该应用**。不是所有应用都允许被嵌:
|
||||
|
||||
- 实测本仓库作者环境 24 个应用里 **19 个可以**,Gitea(含内网 IP)、docker.com 为
|
||||
`X-Frame-Options: SAMEORIGIN`,Dify 为 `DENY`
|
||||
- 是否可嵌由 `GET /admin/api/frameable` **服务端探测**决定(浏览器侧无法区分"被拒绝"
|
||||
与"加载中",`onload` 两种情况都会触发)。探测是**三态**:确定可嵌 / 确定被拒 /
|
||||
无法确认(被 WAF 拦成 403、或跳到了别的主机)。第三态照常尝试内嵌,但在工具条里
|
||||
说明原因并保留"在新标签页打开"的退路
|
||||
- 探测**会转发浏览器自己的 Cookie**:门户后面的应用对未登录请求会 302 到登录页,
|
||||
而登录页没有 X-Frame-Options,不转发会话就会把"跳登录页"误判成"可内嵌"
|
||||
- 被拒时:右侧面板给出说明,并**弹出提示卡片**(应用不支持在工作区显示 + 原因 +
|
||||
在新标签页打开),同时清空 iframe 的 `src`,不留一个拒绝渲染的空白框
|
||||
|
||||
### 悬浮控件会"收边",第一次交互只滑出
|
||||
|
||||
工作空间中间是别人的应用,所以搜索球/视图切换/管理入口/备案号默认收在屏幕边缘
|
||||
(各露 14px,备案号 13px)。**第一次点击/触摸只把它滑出来,第二次才真正生效**——
|
||||
否则贴边的按钮会被直接点中(想看搜索框,结果把管理后台打开了)。判据是"元素是否真的
|
||||
露在视口内",所以鼠标悬停已展开时一次点击即生效。6 秒无操作或 Esc 自动收回。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**搜索无结果**:确认容器能访问搜索引擎。
|
||||
|
||||
152
deploy/verify-ui.js
Normal file
152
deploy/verify-ui.js
Normal file
@ -0,0 +1,152 @@
|
||||
/* HomeDash 浏览器验收脚本(部署后跑一遍,覆盖本轮所有改动点)
|
||||
*
|
||||
* 依赖:node + ws(NODE_PATH 指向装了 ws 的目录)、headless chromium
|
||||
* 环境变量:
|
||||
* PORT CDP 端口(chromium 的 --remote-debugging-port)
|
||||
* CK 门户会话 cookie 值(portal_session),用于访问受保护的站点
|
||||
* VPS 对外 IP(headless 用 --host-resolver-rules 把域名指到它)
|
||||
*
|
||||
* 覆盖:① 渲染开销(backdrop-filter 元素数)
|
||||
* ② 后台窄屏是否横向溢出
|
||||
* ③ 工作空间收边与"第一次交互只滑出"(触摸)
|
||||
* ④ 不支持内嵌的应用是否弹出明确提示
|
||||
* ⑤ 分组增删改与「历史访问」保护(只创建/删除自己的测试分组)
|
||||
* 任何运行时异常都会判失败——promise 里抛出的错误很容易被忽略,
|
||||
* 本项目就因此漏掉过一个 ReferenceError 五个版本。
|
||||
*/
|
||||
const h = require('http');
|
||||
const W = require('ws');
|
||||
const fs = require('fs');
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const req = (m, p) => new Promise((res, rej) => {
|
||||
const q = h.request({ host: '127.0.0.1', port: +process.env.PORT, path: p, method: m }, (z) => {
|
||||
let a = ''; z.on('data', (d) => { a += d; }); z.on('end', () => res(JSON.parse(a)));
|
||||
});
|
||||
q.on('error', rej); q.end();
|
||||
});
|
||||
|
||||
async function conn(url) {
|
||||
const w = new W(url);
|
||||
await new Promise((r) => w.on('open', r));
|
||||
let id = 0; const waiting = new Map(); const ev = [];
|
||||
w.on('message', (b) => { const x = JSON.parse(b); ev.push(x); if (waiting.has(x.id)) { waiting.get(x.id)(x.result); waiting.delete(x.id); } });
|
||||
const send = (m, params = {}) => new Promise((r) => { const n = ++id; waiting.set(n, r); w.send(JSON.stringify({ id: n, method: m, params })); });
|
||||
send.ev = ev; return send;
|
||||
}
|
||||
|
||||
const BLUR = `(() => {
|
||||
const out = {};
|
||||
document.querySelectorAll('body *').forEach((e) => {
|
||||
const c = getComputedStyle(e);
|
||||
const bf = c.backdropFilter || c.webkitBackdropFilter;
|
||||
if (bf && bf !== 'none') {
|
||||
const k = e.tagName + '.' + (typeof e.className === 'string' ? e.className.slice(0, 22) : '');
|
||||
out[k] = (out[k] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const total = Object.values(out).reduce((a, b) => a + b, 0);
|
||||
return { path: location.pathname, total, byClass: out };
|
||||
})()`;
|
||||
|
||||
const PEEK = `(() => {
|
||||
const box = (s) => { const e = document.querySelector(s); if (!e) return null; const r = e.getBoundingClientRect();
|
||||
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom),
|
||||
cx: Math.round(r.left + r.width / 2), cy: Math.round(r.top + r.height / 2),
|
||||
op: Number(getComputedStyle(e).opacity) }; };
|
||||
const bar = document.querySelector('.navi-search-bar');
|
||||
return { path: location.pathname, pathname: location.pathname, innerW: innerWidth, innerH: innerHeight,
|
||||
peekOpen: document.documentElement.classList.contains('navi-peek-open'),
|
||||
search: box('.navi-search-bar'), dock: box('.navi-dock'),
|
||||
searchState: bar ? bar.dataset.state : null };
|
||||
})()`;
|
||||
|
||||
(async () => {
|
||||
const t = await req('PUT', '/json/new?about:blank');
|
||||
const p = await conn(t.webSocketDebuggerUrl);
|
||||
await p('Page.enable'); await p('Runtime.enable'); await p('Network.enable'); await p('Log.enable');
|
||||
await p('Network.setCookie', { name: 'portal_session', value: process.env.CK, domain: '.example.com', path: '/', secure: true });
|
||||
await p('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
const ev = async (e) => (await p('Runtime.evaluate', { returnByValue: true, expression: e })).result.value;
|
||||
const clickXY = async (x, y) => {
|
||||
await p('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); await sleep(300);
|
||||
for (const type of ['mousePressed', 'mouseReleased']) await p('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
|
||||
await sleep(700);
|
||||
};
|
||||
const clickEl = async (sel, n = 0) => {
|
||||
const b = await ev(`(()=>{const l=document.querySelectorAll(${JSON.stringify(sel)});const e=l[${n}];if(!e)return null;const r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`);
|
||||
if (!b) return false; await clickXY(b.x, b.y); return true;
|
||||
};
|
||||
let fail = 0;
|
||||
|
||||
console.log('══ A) 渲染开销:带 backdrop-filter 的元素数');
|
||||
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
for (const path of ['/', '/minimal']) {
|
||||
await p('Page.navigate', { url: 'https://dashy.example.com' + path });
|
||||
await sleep(8000);
|
||||
const o = await ev(BLUR);
|
||||
console.log(` ${path}: 总数=${o.total} 明细=${JSON.stringify(o.byClass)}`);
|
||||
if (o.total > 16) { fail++; console.log(' ✗ 仍偏多'); }
|
||||
}
|
||||
|
||||
console.log('══ B) 后台窄屏(390px)');
|
||||
await p('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
|
||||
await p('Page.navigate', { url: 'https://dashy.example.com/admin/' });
|
||||
await sleep(7500);
|
||||
const o = await ev(`(() => {
|
||||
const de = document.documentElement;
|
||||
const over = [];
|
||||
document.querySelectorAll('.card, .itm, .sec-fields, input, button, select').forEach((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width > 0 && (r.right > innerWidth + 1 || r.left < -1)) {
|
||||
over.push(e.tagName + '.' + (typeof e.className === 'string' ? e.className.slice(0, 18) : '') + ' [' + Math.round(r.left) + '..' + Math.round(r.right) + ']');
|
||||
}
|
||||
});
|
||||
const secInputs = Array.from(document.querySelectorAll('input[data-sec-name]')).map((i) => { const r = i.getBoundingClientRect(); return Math.round(r.width); });
|
||||
return { innerW: innerWidth, scrollW: de.scrollWidth, canScrollX: de.scrollWidth > innerWidth + 1,
|
||||
overflowCount: over.length, overflow: over.slice(0, 6), secInputWidths: secInputs };
|
||||
})()`);
|
||||
const okB = !o.canScrollX && o.overflowCount === 0;
|
||||
if (!okB) fail++;
|
||||
console.log(` ${okB ? '✓' : '✗'} scrollWidth=${o.scrollW} innerWidth=${o.innerW} 横向溢出元素=${o.overflowCount}`);
|
||||
if (o.overflow.length) console.log(' 溢出:', JSON.stringify(o.overflow));
|
||||
console.log(` 分组名输入框宽度=${JSON.stringify(o.secInputWidths)}`);
|
||||
const shot = await p('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync('/tmp/v29-admin-mobile.png', Buffer.from(shot.data, 'base64'));
|
||||
|
||||
console.log('══ C) 工作空间:收起态第一次点击只滑出、不生效');
|
||||
await p('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
await p('Page.navigate', { url: 'https://dashy.example.com/workspace' });
|
||||
await sleep(8500);
|
||||
let s0 = await ev(PEEK);
|
||||
console.log(` 收起态(视口 ${s0.innerW}x${s0.innerH}): dock 露${s0.innerW - s0.dock.left}px @y=${s0.dock.cy} op=${s0.dock.op} 搜索球露${s0.search.right}px @y=${s0.search.cy} 搜索态=${s0.searchState}`);
|
||||
// 第一次点:贴着右边的那条 sliver(dock 最右 14px)
|
||||
await clickXY(s0.innerW - 7, s0.dock.cy);
|
||||
let s1 = await ev(PEEK);
|
||||
const revealOnly = s1.pathname === '/workspace' && !/\/admin/.test(s1.pathname) && s1.dock.op > 0.9 && s1.dock.right <= s1.innerW - 10;
|
||||
if (!revealOnly) fail++;
|
||||
console.log(` ${revealOnly ? '✓' : '✗'} 第一次点右边缘 → 未跳转(path=${s1.pathname}) 已滑出(露${s1.innerW - s1.dock.left}px op=${s1.dock.op}) peek=${s1.peekOpen}`);
|
||||
// 搜索球:第一次点 sliver 只滑出,不展开输入
|
||||
await p('Page.navigate', { url: 'https://dashy.example.com/workspace' });
|
||||
await sleep(8000);
|
||||
s0 = await ev(PEEK);
|
||||
await clickXY(Math.max(3, s0.search.right - 7), s0.search.cy);
|
||||
s1 = await ev(PEEK);
|
||||
const step1 = s1.searchState === 'collapsed' && s1.search.left >= 0;
|
||||
if (!step1) fail++;
|
||||
console.log(` ${step1 ? '✓' : '✗'} 第一次点搜索球边缘 → 滑出(x=${s1.search.left}) 仍未展开(state=${s1.searchState})`);
|
||||
await clickEl('.navi-search-toggle');
|
||||
const s2 = await ev(PEEK);
|
||||
const step2 = s2.searchState === 'open';
|
||||
if (!step2) fail++;
|
||||
console.log(` ${step2 ? '✓' : '✗'} 第二次点放大镜 → 展开(state=${s2.searchState})`);
|
||||
|
||||
const errs = p.ev.filter((e) => e.method === 'Log.entryAdded' && e.params.entry.level === 'error').map((e) => e.params.entry.text.slice(0, 100));
|
||||
const exc = p.ev.filter((e) => e.method === 'Runtime.exceptionThrown')
|
||||
.map((e) => String((e.params.exceptionDetails.exception && e.params.exceptionDetails.exception.description) || e.params.exceptionDetails.text).split('\n')[0]);
|
||||
console.log('运行时异常:', exc.length ? JSON.stringify(exc, null, 1) : '无');
|
||||
if (exc.length) fail++;
|
||||
console.log('控制台 error:', errs.length ? JSON.stringify(errs.slice(0, 4)) : '无');
|
||||
console.log(fail ? `!! 有 ${fail} 项未通过` : '全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch((e) => { console.error(e.stack); process.exit(1); });
|
||||
166
deploy/zd.sh
Executable file
166
deploy/zd.sh
Executable file
@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# HomeDash 零停机部署
|
||||
#
|
||||
# 为什么需要它:直接 docker stop/rename/run 会有十几秒完全不可用,而且新容器
|
||||
# nginx 先就绪、SearXNG 还在启动,这期间 /search 返回 502。本脚本先在对侧端口
|
||||
# 把新容器验证到底(含 /search,顺带预热 SearXNG),再改门户 nginx 上游并
|
||||
# reload(瞬时切换),最后才停旧容器并保留为回滚点。
|
||||
#
|
||||
# 用法:
|
||||
# deploy/zd.sh <version> 例: deploy/zd.sh v29
|
||||
#
|
||||
# 环境变量(默认值即本项目生产环境,可覆盖):
|
||||
# NAVI_HOST 目标机 ssh 地址,默认 admin@10.0.0.10
|
||||
# NAVI_SSH_PASS 目标机密码(无 ssh key 时必填)
|
||||
# NAVI_UPSTREAM_HOST 门户 nginx 里 dashy 域名对应的主机名,默认 dashy.example.com
|
||||
# NAVI_PORTAL_CONF 门户 nginx 配置文件绝对路径
|
||||
# NAVI_DATA_DIR 数据卷宿主机路径
|
||||
# NAVI_BASE_IMAGE 构建基础镜像,默认 jianf-navi:latest
|
||||
#
|
||||
# 失败会自动回滚:切上游前失败只清掉新容器(老容器继续服务);切上游后失败
|
||||
# 会把配置还原并 reload。每次改动门户配置前都会先备份。
|
||||
set -euo pipefail
|
||||
|
||||
VER="${1:?用法: deploy/zd.sh <version>}"
|
||||
TAG="homedash:ui-${VER}"
|
||||
STAMP="$(date +%Y%m%d%H%M%S)"
|
||||
HOST="${NAVI_HOST:-admin@10.0.0.10}"
|
||||
SSH_PASS="${NAVI_SSH_PASS:-}"
|
||||
UPSTREAM_HOST="${NAVI_UPSTREAM_HOST:-dashy.example.com}"
|
||||
PORTAL_CONF="${NAVI_PORTAL_CONF:-/vol1/docker/portal/nginx/nginx.conf}"
|
||||
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
|
||||
BASE_IMAGE="${NAVI_BASE_IMAGE:-jianf-navi:latest}"
|
||||
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CTX=/tmp/homedash-ctx
|
||||
TAR="/tmp/homedash-${VER}.tar.gz"
|
||||
REMOTE_TAR="/tmp/homedash-${VER}-${STAMP}.tar.gz"
|
||||
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no)
|
||||
if [ -n "$SSH_PASS" ]; then
|
||||
SSH=(sshpass -p "$SSH_PASS" ssh "${SSH_OPTS[@]}" "$HOST")
|
||||
SCP=(sshpass -p "$SSH_PASS" scp -q "${SSH_OPTS[@]}")
|
||||
else
|
||||
SSH=(ssh "${SSH_OPTS[@]}" "$HOST")
|
||||
SCP=(scp -q "${SSH_OPTS[@]}")
|
||||
fi
|
||||
|
||||
echo "== 1/3 本地构建 ${TAG}(源码 ${SRC})=="
|
||||
cd "$SRC"
|
||||
node --check web/navi.js
|
||||
(cd admin-server && gofmt -l . && go test ./... >/dev/null)
|
||||
(cd history-server && gofmt -l . && go test ./... >/dev/null)
|
||||
rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin"
|
||||
(cd history-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/history-server" .)
|
||||
(cd admin-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/admin-server" .)
|
||||
cp nginx.conf supervisord.conf searxng-settings.yml conf.example.yml "$CTX/"
|
||||
cp admin/index.html "$CTX/admin/index.html"
|
||||
cp web/extension/install.html "$CTX/web/extension/install.html"
|
||||
cp web/navi.js web/navi.css web/patch-index.py "$CTX/web/"
|
||||
cat > "$CTX/Dockerfile" <<EOF
|
||||
FROM ${BASE_IMAGE}
|
||||
COPY runtime/history-server /usr/local/bin/history-server
|
||||
COPY runtime/admin-server /usr/local/bin/admin-server
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY searxng-settings.yml /etc/searxng/settings.yml
|
||||
COPY conf.example.yml /app/defaults/conf.yml
|
||||
COPY admin/index.html /app/admin/index.html
|
||||
COPY web/extension/install.html /app/dashy/extension/install.html
|
||||
COPY web/navi.js /app/dashy/navi.js
|
||||
COPY web/navi.css /app/dashy/navi.css
|
||||
COPY web/patch-index.py /tmp/patch-index.py
|
||||
RUN python3 /tmp/patch-index.py /app/dashy/index.html
|
||||
EOF
|
||||
DOCKER_BUILDKIT=0 docker build -q -t "$TAG" "$CTX"
|
||||
docker save "$TAG" | gzip -1 > "$TAR"
|
||||
echo " 镜像与包就绪: $(ls -sh "$TAR" | awk '{print $1}')"
|
||||
|
||||
echo "== 2/3 上传 =="
|
||||
"${SCP[@]}" "$TAR" "$HOST:$REMOTE_TAR"
|
||||
|
||||
echo "== 3/3 远端零停机切换 =="
|
||||
"${SSH[@]}" "TAG='$TAG' STAMP='$STAMP' REMOTE_TAR='$REMOTE_TAR' VER='$VER' \
|
||||
PORTAL_CONF='$PORTAL_CONF' UPSTREAM_HOST='$UPSTREAM_HOST' DATA_DIR='$DATA_DIR' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
CUR_PORT=$(grep -oP "${UPSTREAM_HOST//./\\.}\\s+http://127\\.0\\.0\\.1:\\K[0-9]+" "$PORTAL_CONF" | head -1)
|
||||
[ -n "$CUR_PORT" ] || { echo "!! 读不到门户上游端口" >&2; exit 1; }
|
||||
if [ "$CUR_PORT" = "3003" ]; then NEW_PORT=3004; else NEW_PORT=3003; fi
|
||||
echo " 上游端口 ${CUR_PORT} → 新端口 ${NEW_PORT}"
|
||||
CONF_BAK="${PORTAL_CONF}.bak-${STAMP}"
|
||||
cp -a "$PORTAL_CONF" "$CONF_BAK"
|
||||
|
||||
gzip -dc "$REMOTE_TAR" | docker load >/dev/null
|
||||
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|DEBIAN_FRONTEND)=' > "/tmp/navi-${STAMP}.env"
|
||||
|
||||
flipped=0
|
||||
on_err() {
|
||||
rc=$?
|
||||
echo "!! 失败(rc=$rc),回滚" >&2
|
||||
if [ "$flipped" = "1" ]; then
|
||||
cp -a "$CONF_BAK" "$PORTAL_CONF"
|
||||
docker exec portal-nginx nginx -t && docker exec portal-nginx nginx -s reload || true
|
||||
fi
|
||||
docker rm -f navi-next >/dev/null 2>&1 || true
|
||||
exit $rc
|
||||
}
|
||||
trap on_err ERR
|
||||
|
||||
docker rm -f navi-next >/dev/null 2>&1 || true
|
||||
docker run -d --name navi-next --restart unless-stopped --network bridge \
|
||||
-p "${NEW_PORT}:8080" --env-file "/tmp/navi-${STAMP}.env" \
|
||||
-v "${DATA_DIR}:/app/data" "homedash:ui-${VER}" >/dev/null
|
||||
|
||||
w200() { for _ in $(seq 1 90); do [ "$(curl -s -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; }
|
||||
w200 "http://127.0.0.1:${NEW_PORT}/conf.yml"
|
||||
w200 "http://127.0.0.1:${NEW_PORT}/admin"
|
||||
w200 "http://127.0.0.1:${NEW_PORT}/navi.js"
|
||||
# /search 会真正跑一次搜索:既确认 SearXNG 可用,也顺带把它预热,
|
||||
# 这样切过去之后用户不会撞上"nginx 就绪但 SearXNG 还在启动"的 502。
|
||||
w200 "http://127.0.0.1:${NEW_PORT}/search?q=linux"
|
||||
echo " 新容器全绿,开始切换上游"
|
||||
|
||||
sed "s#\\(${UPSTREAM_HOST//./\\.}\\s*http://127\\.0\\.0\\.1:\\)${CUR_PORT};#\\1${NEW_PORT};#" "$PORTAL_CONF" > /tmp/portal.conf.new
|
||||
# 关键:必须同 inode 覆写(cat > 而不是 sed -i / mv)。
|
||||
# 该文件是以单文件 bind mount 挂进 portal-nginx 的,且挂载是只读的;
|
||||
# 用 sed -i / mv 会换新 inode,容器仍指向旧(孤儿)inode,reload 读到的还是旧端口
|
||||
# → 上游被切走而新端口无人应答 → 全站 502(本项目真实踩过)。
|
||||
cat /tmp/portal.conf.new > "$PORTAL_CONF"
|
||||
grep -q "${UPSTREAM_HOST} *http://127.0.0.1:${NEW_PORT};" "$PORTAL_CONF" || { echo "!! 宿主机配置未改写" >&2; false; }
|
||||
|
||||
# 容器看不到新值时(历史上被 sed -i 换过 inode)只能重启它重新绑定,
|
||||
# 这会断开所有域名约 1 秒,所以先走 reload 路径。
|
||||
if ! docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf; then
|
||||
echo " 容器未看到新配置(inode 错位)→ 重启 portal-nginx"
|
||||
docker restart portal-nginx >/dev/null
|
||||
sleep 4
|
||||
fi
|
||||
docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf \
|
||||
|| { echo "!! 容器内配置仍是旧端口" >&2; false; }
|
||||
docker exec portal-nginx nginx -t
|
||||
docker exec portal-nginx nginx -s reload
|
||||
sleep 2
|
||||
flipped=1
|
||||
|
||||
# 切后哨兵:经门户访问不得是 502/503/504(未登录正常是 302)。
|
||||
# 这是"上游切走但新端口无人应答"唯一能看到的现象。
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${UPSTREAM_HOST}" http://127.0.0.1/conf.yml)
|
||||
case "$CODE" in
|
||||
502|503|504) echo "!! 门户访问返回 $CODE(上游不可达)" >&2; false ;;
|
||||
esac
|
||||
echo " 上游已切到 ${NEW_PORT}(门户探测 $CODE)"
|
||||
|
||||
w200 "http://127.0.0.1:${NEW_PORT}/admin"
|
||||
docker stop navi >/dev/null
|
||||
docker rename navi "navi-prev-${STAMP}"
|
||||
docker rename navi-next navi
|
||||
trap - ERR
|
||||
|
||||
for n in $(docker ps -a --filter name=navi-prev --format '{{.Names}}'); do
|
||||
case "$n" in *"$STAMP"*) ;; *) docker rm -f "$n" >/dev/null;; esac
|
||||
done
|
||||
rm -f "$REMOTE_TAR"
|
||||
echo " 配置备份=$CONF_BAK 回滚点=navi-prev-${STAMP}"
|
||||
echo "DEPLOY_OK tag=homedash:ui-${VER} port=${NEW_PORT} health=$(docker inspect navi --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}')"
|
||||
REMOTE
|
||||
Reference in New Issue
Block a user