feat: run mnote-web prod under systemd --user with auto-restart
Replace detached spawn with mnote-web-prod.service (Restart=always, enable + Linger). prod:start installs the unit, writes env/current symlink, and restarts via systemctl so the gateway survives kills and logout. Also drop unused std::fs import in web_shell.
This commit is contained in:
+249
-27
@@ -9,6 +9,10 @@
|
||||
* - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。
|
||||
* - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。
|
||||
* - 默认启动端口是 3003,避免占用开发端口 3000。
|
||||
* - 进程由 systemd --user(mnote-web-prod.service)托管:Restart=always + enable,
|
||||
* 本机需 loginctl Linger=yes(已配置则登出后仍运行)。
|
||||
* - 管理:systemctl --user status|restart|stop mnote-web-prod.service
|
||||
* 日志:journalctl --user -u mnote-web-prod.service -f
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -23,11 +27,47 @@ const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web");
|
||||
const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web");
|
||||
const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod");
|
||||
const RUN_STATE_PATH = path.join(RUN_DIR, "process.json");
|
||||
const RUN_ENV_PATH = path.join(RUN_DIR, "env");
|
||||
const RUN_CURRENT_LINK = path.join(RUN_DIR, "current");
|
||||
const SYSTEMD_UNIT_NAME = "mnote-web-prod.service";
|
||||
const SYSTEMD_UNIT_SOURCE = path.join(ROOT, "deploy", "systemd", "mnote-web-prod.service");
|
||||
const SYSTEMD_USER_DIR = path.join(os.homedir(), ".config", "systemd", "user");
|
||||
const SYSTEMD_UNIT_TARGET = path.join(SYSTEMD_USER_DIR, SYSTEMD_UNIT_NAME);
|
||||
const DEFAULT_PORT = 3003;
|
||||
const DEFAULT_TURSO_LOCAL_PATH = "/mnt/Data1T/Mnote_data/control-plane/control-plane-prod-libsql.db";
|
||||
const DEFAULT_CONTROL_PLANE_BACKEND = "libsql-local";
|
||||
const ENV_ALL_PATH = path.join(ROOT, ".env.all");
|
||||
const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2";
|
||||
// 不写入 systemd EnvironmentFile 的敏感/会话壳变量
|
||||
const ENV_FILE_DENYLIST = new Set([
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"DISPLAY",
|
||||
"SSH_AUTH_SOCK",
|
||||
"SSH_CLIENT",
|
||||
"SSH_CONNECTION",
|
||||
"SSH_TTY",
|
||||
"XAUTHORITY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"GPG_AGENT_INFO",
|
||||
"OLDPWD",
|
||||
"PWD",
|
||||
"SHLVL",
|
||||
"_",
|
||||
"npm_config_user_agent",
|
||||
"npm_lifecycle_event",
|
||||
"npm_lifecycle_script",
|
||||
"npm_package_name",
|
||||
"npm_package_version",
|
||||
"npm_command",
|
||||
"npm_execpath",
|
||||
"npm_node_execpath",
|
||||
"INIT_CWD",
|
||||
"COLOR",
|
||||
"COLORTERM",
|
||||
"TERM",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
]);
|
||||
const SUPPLEMENTAL_RUNTIME_ASSETS = [
|
||||
{
|
||||
source: "node_modules/jszip/dist/jszip.min.js",
|
||||
@@ -516,6 +556,174 @@ async function verifyStartupAssets(port) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeEnvFileValue(value) {
|
||||
const text = String(value ?? "");
|
||||
// systemd EnvironmentFile:含空白/引号/反斜杠时用双引号并转义
|
||||
if (/[\s"'\\$`]/.test(text) || text === "") {
|
||||
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function buildProdRuntimeEnv(port, bind, publicBind, controlPlaneBackend) {
|
||||
// 只落 .env.all + 生产覆盖,不把整份 shell/npm 环境写进 systemd
|
||||
const env = {
|
||||
...loadEnvFile(ENV_ALL_PATH),
|
||||
};
|
||||
// 允许调用方在进程环境里临时覆盖关键项(如 MNOTE_PROD_PORT)
|
||||
const passThroughKeys = [
|
||||
"MNOTE_PROD_PORT",
|
||||
"MNOTE_WEB_BIND",
|
||||
"MNOTE_WEB_PUBLIC_BIND",
|
||||
"MNOTE_CONTROL_PLANE_BACKEND",
|
||||
"MNOTE_TURSO_LOCAL_PATH",
|
||||
"MNOTE_TURSO_URL",
|
||||
"MNOTE_TURSO_AUTH_TOKEN",
|
||||
"MNOTE_TURSO_SYNC_URL",
|
||||
"RUST_LOG",
|
||||
"MNOTE_LIGHTRAG_API_KEY",
|
||||
"MNOTE_LIGHTRAG_BASE_URL",
|
||||
"LIGHTRAG_URL",
|
||||
"LIGHTRAG_API_KEY",
|
||||
];
|
||||
for (const key of passThroughKeys) {
|
||||
if (process.env[key] !== undefined && process.env[key] !== "") {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
env.MNOTE_WEB_BIND = bind;
|
||||
env.MNOTE_WEB_PUBLIC_BIND = publicBind;
|
||||
env.MNOTE_CONTROL_PLANE_BACKEND = controlPlaneBackend;
|
||||
env.RUST_LOG = env.RUST_LOG || process.env.RUST_LOG || "info";
|
||||
if (
|
||||
controlPlaneBackend === "libsql-local"
|
||||
|| controlPlaneBackend === "turso-local"
|
||||
|| controlPlaneBackend === "turso"
|
||||
) {
|
||||
env.MNOTE_TURSO_LOCAL_PATH =
|
||||
env.MNOTE_TURSO_LOCAL_PATH
|
||||
|| process.env.MNOTE_TURSO_LOCAL_PATH
|
||||
|| DEFAULT_TURSO_LOCAL_PATH;
|
||||
}
|
||||
env.MNOTE_PROD_PORT = String(port);
|
||||
// PATH 给子进程一个干净可预期值(不依赖 npm 注入的 node_modules/.bin)
|
||||
env.PATH = [
|
||||
path.join(os.homedir(), ".local", "bin"),
|
||||
path.join(os.homedir(), ".cargo", "bin"),
|
||||
path.join(os.homedir(), ".npm-global", "bin"),
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
].join(":");
|
||||
env.HOME = os.homedir();
|
||||
env.LANG = process.env.LANG || "C.UTF-8";
|
||||
return env;
|
||||
}
|
||||
|
||||
async function writeSystemdEnvFile(env) {
|
||||
const lines = [
|
||||
"# Generated by scripts/prod-build-start.js — do not edit by hand",
|
||||
`# generatedAt=${new Date().toISOString()}`,
|
||||
];
|
||||
const keys = Object.keys(env).sort();
|
||||
for (const key of keys) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (ENV_FILE_DENYLIST.has(key)) continue;
|
||||
if (key.startsWith("npm_") || key.startsWith("NPM_")) continue;
|
||||
const value = env[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
// 跳过多行值,避免 EnvironmentFile 解析失败
|
||||
if (String(value).includes("\n") || String(value).includes("\r")) continue;
|
||||
lines.push(`${key}=${escapeEnvFileValue(value)}`);
|
||||
}
|
||||
await fsp.mkdir(RUN_DIR, { recursive: true });
|
||||
await fsp.writeFile(RUN_ENV_PATH, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
await fsp.chmod(RUN_ENV_PATH, 0o600);
|
||||
}
|
||||
|
||||
async function installSystemdUserUnit() {
|
||||
if (!(await pathExists(SYSTEMD_UNIT_SOURCE))) {
|
||||
throw new Error(`缺少 systemd unit 模板: ${SYSTEMD_UNIT_SOURCE}`);
|
||||
}
|
||||
await fsp.mkdir(SYSTEMD_USER_DIR, { recursive: true });
|
||||
const sourceText = await fsp.readFile(SYSTEMD_UNIT_SOURCE, "utf8");
|
||||
let needWrite = true;
|
||||
if (await pathExists(SYSTEMD_UNIT_TARGET)) {
|
||||
const existing = await fsp.readFile(SYSTEMD_UNIT_TARGET, "utf8");
|
||||
needWrite = existing !== sourceText;
|
||||
}
|
||||
if (needWrite) {
|
||||
await fsp.writeFile(SYSTEMD_UNIT_TARGET, sourceText, "utf8");
|
||||
console.log(`[prod] 已安装 user unit: ${SYSTEMD_UNIT_TARGET}`);
|
||||
}
|
||||
runChecked("systemctl", ["--user", "daemon-reload"]);
|
||||
// enable 保证登录/linger 后自动拉起
|
||||
runChecked("systemctl", ["--user", "enable", SYSTEMD_UNIT_NAME]);
|
||||
}
|
||||
|
||||
async function pointCurrentRelease(releaseDir) {
|
||||
await fsp.mkdir(RUN_DIR, { recursive: true });
|
||||
const tmpLink = `${RUN_CURRENT_LINK}.new-${process.pid}`;
|
||||
try {
|
||||
await fsp.unlink(tmpLink);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await fsp.symlink(releaseDir, tmpLink);
|
||||
// 原子替换 current → 新 release
|
||||
await fsp.rename(tmpLink, RUN_CURRENT_LINK);
|
||||
}
|
||||
|
||||
function systemctlUser(args, options = {}) {
|
||||
return runChecked("systemctl", ["--user", ...args], options);
|
||||
}
|
||||
|
||||
function systemctlUserStatusText() {
|
||||
try {
|
||||
return execFileSync("systemctl", ["--user", "status", SYSTEMD_UNIT_NAME, "--no-pager"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
return (error.stdout || "") + (error.stderr || "") || String(error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
function mainPidFromSystemd() {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"systemctl",
|
||||
["--user", "show", SYSTEMD_UNIT_NAME, "-p", "MainPID", "--value"],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
const pid = Number(out);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSystemdManagedService(port) {
|
||||
// 先停 systemd 托管(若已 enable)
|
||||
try {
|
||||
const active = execFileSync(
|
||||
"systemctl",
|
||||
["--user", "is-active", SYSTEMD_UNIT_NAME],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
if (active === "active" || active === "activating" || active === "reloading") {
|
||||
console.log(`[prod] systemctl --user stop ${SYSTEMD_UNIT_NAME}`);
|
||||
systemctlUser(["stop", SYSTEMD_UNIT_NAME]);
|
||||
}
|
||||
} catch {
|
||||
// unit 未安装 / 未 active:忽略
|
||||
}
|
||||
|
||||
// 再清掉历史 detached 裸进程 / process.json
|
||||
await stopPreviousProcess(port);
|
||||
}
|
||||
|
||||
async function startRelease(release) {
|
||||
const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
@@ -523,11 +731,9 @@ async function startRelease(release) {
|
||||
}
|
||||
|
||||
await fsp.mkdir(RUN_DIR, { recursive: true });
|
||||
await stopPreviousProcess(port);
|
||||
|
||||
const binaryPath = path.join(release.releaseDir, "mnote-web");
|
||||
const logPath = path.join(release.releaseDir, "mnote-web.log");
|
||||
const logFd = fs.openSync(logPath, "a");
|
||||
const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`;
|
||||
const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port);
|
||||
const controlPlaneBackend =
|
||||
@@ -535,31 +741,41 @@ async function startRelease(release) {
|
||||
if (controlPlaneBackend === "sqlite") {
|
||||
throw new Error("prod runtime 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-remote/turso-local-replica/turso-synced");
|
||||
}
|
||||
if (!(await pathExists(binaryPath))) {
|
||||
throw new Error(`release 二进制不存在: ${binaryPath}`);
|
||||
}
|
||||
|
||||
const child = spawn(binaryPath, [], {
|
||||
cwd: release.metadata?.sourceDir || release.releaseDir,
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: bind,
|
||||
MNOTE_WEB_PUBLIC_BIND: publicBind,
|
||||
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
||||
...(controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso"
|
||||
? {
|
||||
MNOTE_TURSO_LOCAL_PATH:
|
||||
process.env.MNOTE_TURSO_LOCAL_PATH || DEFAULT_TURSO_LOCAL_PATH,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// 1) 停旧(systemd + 裸进程)
|
||||
await stopSystemdManagedService(port);
|
||||
|
||||
child.unref();
|
||||
fs.closeSync(logFd);
|
||||
// 2) 写 env / current 软链 / unit
|
||||
const runtimeEnv = buildProdRuntimeEnv(port, bind, publicBind, controlPlaneBackend);
|
||||
await writeSystemdEnvFile(runtimeEnv);
|
||||
await pointCurrentRelease(release.releaseDir);
|
||||
await installSystemdUserUnit();
|
||||
|
||||
// 3) 由 systemd 拉起(崩溃自动重启、linger 登录后自启)
|
||||
console.log(`[prod] systemctl --user restart ${SYSTEMD_UNIT_NAME}`);
|
||||
systemctlUser(["restart", SYSTEMD_UNIT_NAME]);
|
||||
|
||||
// 等 MainPID 出现
|
||||
let pid = null;
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
pid = mainPidFromSystemd();
|
||||
if (pid) break;
|
||||
await sleep(250);
|
||||
}
|
||||
if (!pid) {
|
||||
throw new Error(
|
||||
`systemd 未给出 MainPID,status:\n${systemctlUserStatusText()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const state = {
|
||||
schema: "mnote.prod_process.v1",
|
||||
pid: child.pid,
|
||||
schema: "mnote.prod_process.v2",
|
||||
managedBy: "systemd-user",
|
||||
unit: SYSTEMD_UNIT_NAME,
|
||||
pid,
|
||||
version: release.version,
|
||||
gitHead: release.metadata?.gitHead,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -567,19 +783,25 @@ async function startRelease(release) {
|
||||
publicBind,
|
||||
releaseDir: release.releaseDir,
|
||||
binaryPath,
|
||||
currentLink: RUN_CURRENT_LINK,
|
||||
envPath: RUN_ENV_PATH,
|
||||
logPath,
|
||||
journal: `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`,
|
||||
};
|
||||
await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
await waitForReady(port, logPath, child.pid);
|
||||
|
||||
await waitForReady(port, logPath, pid);
|
||||
await verifyStartupAssets(port);
|
||||
|
||||
console.log(`[prod] 已启动 mnote-web release ${release.version}`);
|
||||
console.log(`[prod] pid=${child.pid}`);
|
||||
console.log(`[prod] 已启动 mnote-web release ${release.version}(systemd --user)`);
|
||||
console.log(`[prod] unit=${SYSTEMD_UNIT_NAME} pid=${pid}`);
|
||||
console.log(`[prod] local URL=http://127.0.0.1:${port}`);
|
||||
for (const address of lanAddresses()) {
|
||||
console.log(`[prod] LAN URL=http://${address}:${port}`);
|
||||
}
|
||||
console.log(`[prod] log=${logPath}`);
|
||||
console.log(`[prod] journal: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`);
|
||||
console.log(`[prod] log file=${logPath}(历史;新日志优先 journal)`);
|
||||
console.log(`[prod] enable+linger: 开机/登出后仍保持运行(本机 Linger=yes)`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
Reference in New Issue
Block a user