feat(updates): auto-apply and self-restart on new dashboard versions
Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now" button and hourly auto-check in the UI, for checkouts that are safely fast-forwardable. Reintroduces self-restart (previously removed in edc25ca for cross-environment reliability concerns) per explicit user request.
This commit is contained in:
@@ -90,12 +90,17 @@ describe("POST /api/updates/check", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removed POST /api/updates/apply", () => {
|
||||
it("returns 404 because self-update has been removed", async () => {
|
||||
describe("POST /api/updates/apply", () => {
|
||||
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
|
||||
// The test repo checkout has no update pending (or isn't fast-forwardable
|
||||
// from a bare test run), so this exercises the decline path only — it must
|
||||
// never touch the working tree or process in a test run.
|
||||
const res = await httpFetch("/api/updates/apply", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(res.status, 409);
|
||||
assert.equal(res.body.applied, false);
|
||||
assert.ok(["up_to_date", "not_fast_forwardable"].includes(res.body.reason));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file Triggers the one self-restart path this dashboard has: after
|
||||
* `POST /api/updates/apply` fast-forwards and rebuilds the checkout, the
|
||||
* running process needs to relaunch itself on the new code. Reuses the
|
||||
* existing SIGTERM graceful-shutdown path in `server/index.js` (closes
|
||||
* websockets, drains the HTTP server, closes the DB) instead of duplicating
|
||||
* it, and hands the "wait for the old PID to actually die, then spawn the
|
||||
* replacement" job to a detached helper script — the dying process can't do
|
||||
* that itself without racing its own replacement for the port.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
/**
|
||||
* @param {string} [root] repo root the helper should run from.
|
||||
*/
|
||||
function scheduleRestart(root = path.join(__dirname, "..", "..")) {
|
||||
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
||||
const entry = process.argv[1] || path.join(root, "server", "index.js");
|
||||
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
});
|
||||
helper.unref();
|
||||
process.kill(process.pid, "SIGTERM");
|
||||
}
|
||||
|
||||
module.exports = { scheduleRestart };
|
||||
@@ -54,6 +54,24 @@ function execGit(cwd, args, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
||||
* {@link applyUpdate} runs after fast-forwarding. */
|
||||
function execNpm(cwd, args, opts = {}) {
|
||||
const timeout = opts.timeout ?? 300_000;
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
npmCmd,
|
||||
args,
|
||||
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) reject(new Error(stderr || err.message || String(err)));
|
||||
else resolve(String(stdout).trim());
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function listRemotes(gitRoot) {
|
||||
try {
|
||||
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
|
||||
@@ -273,4 +291,47 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getUpdatesStatus, DEFAULT_ROOT };
|
||||
/**
|
||||
* Fast-forwards the checkout to the canonical remote and rebuilds, for the
|
||||
* "Update now" button — the one path in this dashboard that mutates the
|
||||
* working tree and restarts the process on its own initiative (everywhere
|
||||
* else, `server/routes/updates.js` only ever prints a command for the user
|
||||
* to run). Only acts when {@link getUpdatesStatus} reports a situation that
|
||||
* is actually fast-forwardable (`tracking_canonical` or
|
||||
* `fork_or_diverged_tracking`); a feature branch or detached HEAD is left
|
||||
* alone since there's no safe automatic move.
|
||||
*
|
||||
* @param {string} [gitRoot]
|
||||
* @returns {Promise<object>} the refreshed status, plus `applied` (and
|
||||
* `reason` when `applied` is false).
|
||||
*/
|
||||
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
|
||||
const status = await getUpdatesStatus(gitRoot);
|
||||
if (!status.update_available) {
|
||||
return { ...status, applied: false, reason: "up_to_date" };
|
||||
}
|
||||
if (
|
||||
status.situation !== "tracking_canonical" &&
|
||||
status.situation !== "fork_or_diverged_tracking"
|
||||
) {
|
||||
return { ...status, applied: false, reason: "not_fast_forwardable" };
|
||||
}
|
||||
|
||||
const root = path.resolve(gitRoot);
|
||||
if (status.situation === "tracking_canonical") {
|
||||
await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 });
|
||||
} else {
|
||||
await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 });
|
||||
await execGit(root, ["merge", "--ff-only", status.remote_ref], { timeout: 30_000 });
|
||||
}
|
||||
|
||||
await execNpm(root, ["run", "setup"]);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
await execNpm(root, ["run", "build"]);
|
||||
}
|
||||
|
||||
const refreshed = await getUpdatesStatus(root, { skipFetch: true });
|
||||
return { ...refreshed, applied: true };
|
||||
}
|
||||
|
||||
module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT };
|
||||
|
||||
@@ -2440,6 +2440,48 @@ function createOpenApiSpec() {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/updates/apply": {
|
||||
post: {
|
||||
tags: ["Updates"],
|
||||
summary: "Pull, rebuild, and restart the dashboard when fast-forwardable",
|
||||
operationId: "applyUpdate",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Checkout fast-forwarded and rebuilt; the process is about to self-restart",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description: "Same shape as GET /api/updates/status, plus applied: true.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"Declined - nothing to apply, or the checkout isn't safely fast-forwardable",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'applied: false plus reason: "up_to_date" | "not_fast_forwardable".',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Update apply failed",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/alerts": {
|
||||
get: {
|
||||
tags: ["Alerts"],
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @file HTTP routes for dashboard upstream-update detection. The dashboard never
|
||||
* restarts itself — users copy the printed command and run it in their terminal.
|
||||
* @file HTTP routes for dashboard upstream-update detection, plus the one
|
||||
* self-applying path: POST /apply fast-forwards, rebuilds, and restarts the
|
||||
* process on the user's explicit click (see `server/lib/update-check.js`'s
|
||||
* `applyUpdate` and `server/lib/self-restart.js`). Everywhere else, the
|
||||
* dashboard only ever prints a command for the user to run themselves.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { getUpdatesStatus } = require("../lib/update-check");
|
||||
const { getUpdatesStatus, applyUpdate } = require("../lib/update-check");
|
||||
const { scheduleRestart } = require("../lib/self-restart");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -37,4 +41,36 @@ router.post("/check", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/apply", async (_req, res) => {
|
||||
try {
|
||||
const result = await applyUpdate();
|
||||
if (!result.applied) {
|
||||
res.status(409).json({
|
||||
...result,
|
||||
error: {
|
||||
code: "UPDATE_NOT_APPLICABLE",
|
||||
message:
|
||||
result.reason === "up_to_date"
|
||||
? "Already up to date."
|
||||
: "Current branch can't be fast-forwarded automatically.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
try {
|
||||
const { broadcast } = require("../websocket");
|
||||
broadcast("update_status", { ...result, update_available: false });
|
||||
} catch {
|
||||
// WS not initialized (e.g. in isolated tests) — safe to ignore.
|
||||
}
|
||||
// After the response above, restart onto the freshly-built code.
|
||||
setImmediate(() => scheduleRestart());
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "UPDATE_APPLY_FAILED", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user