@rackbops/ac-agent 2.0.0-alpha.47 → 2.0.0-alpha.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -6
- package/dist/ac-agent.mjs +246 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,10 +106,58 @@ against the new bundle, `install` stops the old process and starts it again thro
|
|
|
106
106
|
`stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
|
|
107
107
|
process is actually answering (a health check, retried for a few seconds — the OS command that
|
|
108
108
|
launches a process can report success before that process has actually started listening) before
|
|
109
|
-
printing `restarted: now serving <version>`.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
printing `restarted: now serving <version>`. Only a health answer reporting the **new** version
|
|
110
|
+
counts (#659), so the old process still answering on the port is not mistaken for the new one.
|
|
111
|
+
|
|
112
|
+
Before copying the new bundle in over a running agent, `install` keeps the one it replaces as
|
|
113
|
+
`bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install). If the
|
|
114
|
+
new bundle never answers health at its own version, `install` rolls back:
|
|
115
|
+
1. It stops the task/unit again. On Windows this ends `launch.js`'s retry loop, so it can't rerun the
|
|
116
|
+
broken bundle in the middle of the restore.
|
|
117
|
+
2. It restores `ac-agent.prev.mjs` and starts it.
|
|
118
|
+
3. It confirms the previous version answers, then prints
|
|
119
|
+
`rolled back: now serving <previous version> (<reason>)`. The previous version is read from the
|
|
120
|
+
running agent's own health answer just before it is stopped. If that read got no version (no
|
|
121
|
+
`cert.pem` to read it with, no answer, an error answer, or one without a version), any healthy
|
|
122
|
+
answer is accepted, and the line says
|
|
123
|
+
`now serving the previous bundle`.
|
|
124
|
+
|
|
125
|
+
Whenever a rollback is possible, the restart confirmation waits up to a minute instead of a few
|
|
126
|
+
seconds, because a false failure would undo a good release (the Windows cold start has never been
|
|
127
|
+
timed). This applies to a plain install too, such as Tooling's `Update-AcAgent.ps1`. A new bundle that
|
|
128
|
+
never comes up therefore takes about two minutes to report (a minute waiting for it, up to another
|
|
129
|
+
confirming the rollback; a hung connection adds to each attempt). If the install fails before the
|
|
130
|
+
restart (registering the task/unit, the certificate) and a backup was made, the running agent's bundle
|
|
131
|
+
is copied back onto the disk; the error is reported as before.
|
|
132
|
+
|
|
133
|
+
The install then **fails** (exit 1), because it did not take. If the rollback fails too, it prints
|
|
134
|
+
`rollback failed: ...`, leaves both bundles in place, and also exits 1; `ac-agent start` is the
|
|
135
|
+
fallback.
|
|
136
|
+
|
|
137
|
+
When there was no bundle to go back to, a restart that fails outright still reports the install as a
|
|
138
|
+
success, with `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual
|
|
139
|
+
sequence is the fallback. A fresh install, with nothing running yet, prints neither line.
|
|
140
|
+
|
|
141
|
+
`install --update` is what the agent's own update route launches; it isn't meant to be run by hand.
|
|
142
|
+
It differs from a plain install in two ways:
|
|
143
|
+
- Its restart confirmation (when an agent is running to restart) is always the minute-long one,
|
|
144
|
+
even with no backup to roll back to.
|
|
145
|
+
- It records each step in `update.json` in the state dir:
|
|
146
|
+
`{status: "installing" | "ok" | "rolled-back" | "failed", from, target, at, message?}`, where
|
|
147
|
+
`from` is null when the previous version could not be read.
|
|
148
|
+
- Once `installing` is written, the outcome is recorded as `ok`, `rolled-back` or `failed`. That
|
|
149
|
+
covers a failure before the restart, and no agent running afterwards.
|
|
150
|
+
- A failure before `installing` is written (reading the config, the backup copy) leaves the
|
|
151
|
+
previous record as it was.
|
|
152
|
+
- It writes a temp file and renames it over, so a reader never sees a partial record.
|
|
153
|
+
- A write that fails (for example a locked file) prints a `warning: could not record update
|
|
154
|
+
status ...` line and never changes the install's outcome. The record then still shows the last
|
|
155
|
+
write that landed, which may be `installing`, so a reader should treat an old `installing` as
|
|
156
|
+
unknown.
|
|
157
|
+
|
|
158
|
+
A plain install never writes `update.json`. `ac-agent version --json` prints
|
|
159
|
+
`{"version", "apiVersion"}`, which is how a staged bundle's agent API version is read without starting
|
|
160
|
+
it.
|
|
113
161
|
|
|
114
162
|
### The firewall step
|
|
115
163
|
|
|
@@ -223,7 +271,8 @@ when this session's own AppData writes to the resolved config/state dirs are vir
|
|
|
223
271
|
packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
|
|
224
272
|
session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
|
|
225
273
|
README, Install` line to stderr. `serve` logs the same warning line once at startup, in the same
|
|
226
|
-
circumstance, without refusing to start.
|
|
274
|
+
circumstance, without refusing to start. `update` (#659) is the last `install --update`'s record from
|
|
275
|
+
`update.json`, or `null` when there is none or it is unreadable.
|
|
227
276
|
|
|
228
277
|
## Pair
|
|
229
278
|
|
|
@@ -275,7 +324,7 @@ or `systemctl --user stop/start`) without touching the registration itself.
|
|
|
275
324
|
| | Windows | Linux |
|
|
276
325
|
|---|---|---|
|
|
277
326
|
| Config (`agent.toml`) | `%APPDATA%\ac-agent\` | `~/.config/ac-agent/` |
|
|
278
|
-
| State (cert, key, token hash, grants, log, pid, the installed bundle copy) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
|
|
327
|
+
| State (cert, key, token hash, grants, log, pid, the installed bundle copy and its one backup, `update.json`) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
|
|
279
328
|
|
|
280
329
|
Both are overridable with `AC_AGENT_CONFIG_DIR` / `AC_AGENT_STATE_DIR` — **absolute paths only**; a
|
|
281
330
|
relative override throws, naming the value. `install` resolves these once, in its own process, and
|
package/dist/ac-agent.mjs
CHANGED
|
@@ -47,7 +47,7 @@ function _mergeNamespaces(n, m) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
var name = "@rackbops/ac-agent";
|
|
50
|
-
var version = "2.0.0-alpha.
|
|
50
|
+
var version = "2.0.0-alpha.48";
|
|
51
51
|
var type = "module";
|
|
52
52
|
var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
|
|
53
53
|
var bin = {
|
|
@@ -21945,36 +21945,67 @@ function detectVirtualization(deps) {
|
|
|
21945
21945
|
function bundleTargetPath(stateDir) {
|
|
21946
21946
|
return path.join(stateDir, "bin", "ac-agent.mjs");
|
|
21947
21947
|
}
|
|
21948
|
-
|
|
21949
|
-
|
|
21950
|
-
|
|
21951
|
-
|
|
21952
|
-
|
|
21953
|
-
|
|
21954
|
-
|
|
21955
|
-
|
|
21956
|
-
|
|
21957
|
-
|
|
21958
|
-
|
|
21959
|
-
|
|
21960
|
-
|
|
21961
|
-
|
|
21962
|
-
|
|
21963
|
-
|
|
21964
|
-
|
|
21965
|
-
|
|
21966
|
-
|
|
21967
|
-
|
|
21968
|
-
|
|
21969
|
-
|
|
21970
|
-
|
|
21948
|
+
/** The one previous bundle `install` keeps while it restarts onto a new one (#659): restored when
|
|
21949
|
+
* the new bundle never answers health at its own version. Overwritten by the next install. */
|
|
21950
|
+
function bundleBackupPath(stateDir) {
|
|
21951
|
+
return path.join(stateDir, "bin", "ac-agent.prev.mjs");
|
|
21952
|
+
}
|
|
21953
|
+
function updateRecordPath(stateDir) {
|
|
21954
|
+
return path.join(stateDir, "update.json");
|
|
21955
|
+
}
|
|
21956
|
+
/** Best-effort: the record reports an install's outcome, it never decides it. A write that fails
|
|
21957
|
+
* (on Windows, `renameSync` over a file a reader holds open can throw EPERM/EBUSY; a full disk) is
|
|
21958
|
+
* printed and swallowed -- left to throw inside the restart sequence, it would roll back a release
|
|
21959
|
+
* that came up healthy (#659 review round 1). */
|
|
21960
|
+
function writeUpdateRecord(deps, record) {
|
|
21961
|
+
const target = updateRecordPath(deps.stateDir);
|
|
21962
|
+
const tmp = `${target}.tmp`;
|
|
21963
|
+
try {
|
|
21964
|
+
deps.writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
21965
|
+
deps.renameFile(tmp, target);
|
|
21966
|
+
}
|
|
21967
|
+
catch (err) {
|
|
21968
|
+
deps.print(`warning: could not record update status ${record.status} in ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
|
21969
|
+
}
|
|
21970
|
+
}
|
|
21971
|
+
/** `update.json` parsed, or null when absent or unreadable -- `status` reports it as-is. */
|
|
21972
|
+
function readUpdateRecord(deps) {
|
|
21973
|
+
const text = deps.readFile(updateRecordPath(deps.stateDir));
|
|
21974
|
+
if (text === null)
|
|
21975
|
+
return null;
|
|
21976
|
+
try {
|
|
21977
|
+
const value = JSON.parse(text);
|
|
21978
|
+
return typeof value === "object" && value !== null && typeof value.status === "string"
|
|
21979
|
+
? value
|
|
21980
|
+
: null;
|
|
21981
|
+
}
|
|
21982
|
+
catch {
|
|
21983
|
+
return null;
|
|
21984
|
+
}
|
|
21985
|
+
}
|
|
21986
|
+
const HEALTH_BUDGET = { attempts: 8, delayMs: 400 };
|
|
21987
|
+
/** `install --update` (#659): a false failure here rolls back a good release, so the update path
|
|
21988
|
+
* waits up to a minute -- the Windows cold start (Start-ScheduledTask -> wscript -> node) has never
|
|
21989
|
+
* been timed live, see above. */
|
|
21990
|
+
const UPDATE_HEALTH_BUDGET = { attempts: 60, delayMs: 1000 };
|
|
21991
|
+
/** Waits for a health answer that is `ok` and, when `expectedVersion` is given, reports exactly that
|
|
21992
|
+
* version (#659): the old process still answering on the port is not the new bundle coming up. */
|
|
21993
|
+
async function confirmHealthy(deps, port, certPem, expectedFingerprint, expectedVersion, budget) {
|
|
21994
|
+
let lastVersion;
|
|
21995
|
+
for (let attempt = 0; attempt < budget.attempts; attempt++) {
|
|
21971
21996
|
const health = await deps.fetchHealth(port, certPem, expectedFingerprint);
|
|
21972
|
-
if (health?.ok)
|
|
21973
|
-
|
|
21974
|
-
|
|
21975
|
-
|
|
21997
|
+
if (health?.ok) {
|
|
21998
|
+
if (expectedVersion === null || health.version === expectedVersion)
|
|
21999
|
+
return;
|
|
22000
|
+
lastVersion = health.version;
|
|
22001
|
+
}
|
|
22002
|
+
if (attempt < budget.attempts - 1) {
|
|
22003
|
+
await new Promise((resolve) => setTimeout(resolve, budget.delayMs));
|
|
21976
22004
|
}
|
|
21977
22005
|
}
|
|
22006
|
+
if (lastVersion !== undefined) {
|
|
22007
|
+
throw new Error(`port ${port} still answers version ${lastVersion}, not ${expectedVersion}`);
|
|
22008
|
+
}
|
|
21978
22009
|
throw new Error(`new process did not answer a health check on port ${port}`);
|
|
21979
22010
|
}
|
|
21980
22011
|
async function installService(deps) {
|
|
@@ -22028,28 +22059,72 @@ async function installService(deps) {
|
|
|
22028
22059
|
deps.print("warning: AC_AGENT_REPOS_ROOT overrides repos_root; the saved value will take effect when it is unset");
|
|
22029
22060
|
}
|
|
22030
22061
|
const bundlePath = bundleTargetPath(deps.stateDir);
|
|
22031
|
-
|
|
22032
|
-
|
|
22033
|
-
|
|
22034
|
-
|
|
22035
|
-
|
|
22036
|
-
|
|
22037
|
-
|
|
22038
|
-
|
|
22039
|
-
|
|
22040
|
-
|
|
22041
|
-
|
|
22042
|
-
|
|
22043
|
-
|
|
22044
|
-
|
|
22045
|
-
|
|
22046
|
-
|
|
22062
|
+
const backupPath = bundleBackupPath(deps.stateDir);
|
|
22063
|
+
// #659: an install over a running agent keeps the bundle it is replacing, and the version that
|
|
22064
|
+
// bundle is serving, so a new bundle that never comes up can be undone. Read before the copy: once
|
|
22065
|
+
// the copy lands, the file on disk is no longer what the running process loaded.
|
|
22066
|
+
const wasRunning = isAgentRunning(deps);
|
|
22067
|
+
let backedUp = false;
|
|
22068
|
+
let previousVersion = null;
|
|
22069
|
+
if (wasRunning && deps.exists(bundlePath)) {
|
|
22070
|
+
previousVersion = await runningVersion(deps, port);
|
|
22071
|
+
deps.copyFile(bundlePath, backupPath);
|
|
22072
|
+
backedUp = true;
|
|
22073
|
+
}
|
|
22074
|
+
const record = (status, message) => {
|
|
22075
|
+
if (!deps.updateMode)
|
|
22076
|
+
return;
|
|
22077
|
+
writeUpdateRecord(deps, {
|
|
22078
|
+
status,
|
|
22079
|
+
from: previousVersion,
|
|
22080
|
+
target: deps.version,
|
|
22081
|
+
at: nowIso(deps),
|
|
22082
|
+
...(message !== undefined ? { message } : {}),
|
|
22083
|
+
});
|
|
22084
|
+
};
|
|
22085
|
+
record("installing");
|
|
22086
|
+
let certPem;
|
|
22087
|
+
let fp;
|
|
22088
|
+
let result;
|
|
22089
|
+
try {
|
|
22090
|
+
deps.copyFile(deps.bundleSourcePath, bundlePath);
|
|
22091
|
+
certPem = (await ensureCert({
|
|
22092
|
+
stateDir: deps.stateDir,
|
|
22093
|
+
hostname: deps.hostname(),
|
|
22094
|
+
ips: deps.ips,
|
|
22095
|
+
readFile: deps.readFile,
|
|
22096
|
+
writeFile: (p, content, mode) => deps.writeFile(p, content, mode),
|
|
22097
|
+
})).certPem;
|
|
22098
|
+
fp = fingerprint(certPem);
|
|
22099
|
+
// #263: refuse before registering a task/unit nothing can ever see, rather than leave a broken
|
|
22100
|
+
// registration behind for `status`/`start` to fail against later. Must run after the state-dir
|
|
22101
|
+
// writes above (so a genuinely virtualized session's own writes are what the probe finds) and
|
|
22102
|
+
// before `deps.platform.install` (which is what actually registers the task/unit on Windows).
|
|
22103
|
+
const virtualization = detectVirtualization(deps);
|
|
22104
|
+
if (virtualization.virtualized) {
|
|
22105
|
+
throw new Error(installRefusalMessage(virtualization.packageFamily, virtualization.vfsPath));
|
|
22106
|
+
}
|
|
22107
|
+
result = await deps.platform.install({
|
|
22108
|
+
nodePath: deps.nodePath,
|
|
22109
|
+
bundlePath,
|
|
22110
|
+
user: deps.user,
|
|
22111
|
+
});
|
|
22112
|
+
}
|
|
22113
|
+
catch (err) {
|
|
22114
|
+
// #659 review round 1: a failure before the restart leaves the old process running, so put its
|
|
22115
|
+
// bundle back on disk (the next crash restart must load what was serving, not the half-installed
|
|
22116
|
+
// new one) and record the outcome -- `installing` must never be the last word.
|
|
22117
|
+
if (backedUp) {
|
|
22118
|
+
try {
|
|
22119
|
+
deps.copyFile(backupPath, bundlePath);
|
|
22120
|
+
}
|
|
22121
|
+
catch {
|
|
22122
|
+
// the backup is still in place for a manual restore; the original error is what matters
|
|
22123
|
+
}
|
|
22124
|
+
}
|
|
22125
|
+
record("failed", err instanceof Error ? err.message : String(err));
|
|
22126
|
+
throw err;
|
|
22047
22127
|
}
|
|
22048
|
-
const result = await deps.platform.install({
|
|
22049
|
-
nodePath: deps.nodePath,
|
|
22050
|
-
bundlePath,
|
|
22051
|
-
user: deps.user,
|
|
22052
|
-
});
|
|
22053
22128
|
deps.print(`config: ${path.join(deps.configDir, "agent.toml")} (${loaded.source})`);
|
|
22054
22129
|
deps.print(`state: ${deps.stateDir}`);
|
|
22055
22130
|
deps.print(`fingerprint: ${fp}`);
|
|
@@ -22063,19 +22138,101 @@ async function installService(deps) {
|
|
|
22063
22138
|
// path -- so `stopService`'s #238 ordering (task/unit stop before the orphaned-pid kill) is
|
|
22064
22139
|
// exercised here unchanged, just followed by the same `platform.start()` the `start` verb calls.
|
|
22065
22140
|
if (isAgentRunning(deps)) {
|
|
22141
|
+
// The long budget whenever a rollback is possible, not only under --update: a false failure there
|
|
22142
|
+
// stops a healthy new process and restores the old bundle. Tooling's Update-AcAgent.ps1 runs a
|
|
22143
|
+
// plain `install`, and the Windows cold start has never been timed (#659 review round 1). With
|
|
22144
|
+
// nothing to roll back to, a failure only prints, so the short budget stays.
|
|
22145
|
+
const budget = deps.updateMode || backedUp
|
|
22146
|
+
? (deps.rollbackHealthBudget ?? UPDATE_HEALTH_BUDGET)
|
|
22147
|
+
: HEALTH_BUDGET;
|
|
22148
|
+
let restartError = null;
|
|
22066
22149
|
try {
|
|
22067
22150
|
await stopService(deps);
|
|
22068
22151
|
await deps.platform.start();
|
|
22069
|
-
await confirmHealthy(deps, port, certPem, fp);
|
|
22070
|
-
deps.print(`restarted: now serving ${deps.version}`);
|
|
22152
|
+
await confirmHealthy(deps, port, certPem, fp, deps.version, budget);
|
|
22071
22153
|
}
|
|
22072
22154
|
catch (err) {
|
|
22073
|
-
|
|
22074
|
-
// a restart failure must not be reported as an install failure -- the operator's fallback is
|
|
22075
|
-
// the same manual stop/start #238 already documents.
|
|
22076
|
-
deps.print(`restart failed: ${err instanceof Error ? err.message : String(err)}; run ac-agent stop && ac-agent start`);
|
|
22155
|
+
restartError = err;
|
|
22077
22156
|
}
|
|
22157
|
+
if (restartError === null) {
|
|
22158
|
+
// Recorded only after the restart sequence: a failed record write must never look like a
|
|
22159
|
+
// failed restart (#659 review round 1).
|
|
22160
|
+
deps.print(`restarted: now serving ${deps.version}`);
|
|
22161
|
+
record("ok");
|
|
22162
|
+
return;
|
|
22163
|
+
}
|
|
22164
|
+
const reason = restartError instanceof Error ? restartError.message : String(restartError);
|
|
22165
|
+
if (!backedUp) {
|
|
22166
|
+
// Nothing to go back to (no bundle was in place): the install itself already succeeded (the
|
|
22167
|
+
// task/unit is registered against the new bundle), so a restart failure must not be reported
|
|
22168
|
+
// as an install failure -- the operator's fallback is the manual stop/start #238 documents.
|
|
22169
|
+
deps.print(`restart failed: ${reason}; run ac-agent stop && ac-agent start`);
|
|
22170
|
+
record("failed", reason);
|
|
22171
|
+
return;
|
|
22172
|
+
}
|
|
22173
|
+
await rollBack(deps, {
|
|
22174
|
+
port,
|
|
22175
|
+
certPem,
|
|
22176
|
+
fp,
|
|
22177
|
+
bundlePath,
|
|
22178
|
+
backupPath,
|
|
22179
|
+
previousVersion,
|
|
22180
|
+
budget,
|
|
22181
|
+
reason,
|
|
22182
|
+
});
|
|
22183
|
+
return;
|
|
22078
22184
|
}
|
|
22185
|
+
// Nothing was running to restart (the agent died between the pre-check and now, or a fresh install).
|
|
22186
|
+
// Under --update the agent that launched this install was running, so this is a failure the record
|
|
22187
|
+
// must say, not leave at `installing` (#659 review round 2).
|
|
22188
|
+
record("failed", "no agent was running after the install; run ac-agent start");
|
|
22189
|
+
}
|
|
22190
|
+
function nowIso(deps) {
|
|
22191
|
+
return (deps.now ?? (() => new Date()))().toISOString();
|
|
22192
|
+
}
|
|
22193
|
+
/** The version the running agent reports before it is stopped (#659), read with the cert already in
|
|
22194
|
+
* the state dir -- null when there is no cert yet or the agent does not answer. */
|
|
22195
|
+
async function runningVersion(deps, port) {
|
|
22196
|
+
const certPem = deps.readFile(path.join(deps.stateDir, "cert.pem"));
|
|
22197
|
+
if (certPem === null)
|
|
22198
|
+
return null;
|
|
22199
|
+
const health = await deps.fetchHealth(port, certPem, fingerprint(certPem));
|
|
22200
|
+
return health?.ok && typeof health.version === "string" ? health.version : null;
|
|
22201
|
+
}
|
|
22202
|
+
/** #659: the new bundle never answered health at its own version, so restore the one it replaced.
|
|
22203
|
+
* Stops first, in #238's order (the task/unit, then the pid) -- on Windows that ends `launch.js`'s
|
|
22204
|
+
* retry loop, which would otherwise rerun the broken bundle a minute later, mid-restore. Throws
|
|
22205
|
+
* either way, so `install` exits 1: the install did not take. */
|
|
22206
|
+
async function rollBack(deps, ctx) {
|
|
22207
|
+
const record = (status, message) => {
|
|
22208
|
+
if (!deps.updateMode)
|
|
22209
|
+
return;
|
|
22210
|
+
writeUpdateRecord(deps, {
|
|
22211
|
+
status,
|
|
22212
|
+
from: ctx.previousVersion,
|
|
22213
|
+
target: deps.version,
|
|
22214
|
+
at: nowIso(deps),
|
|
22215
|
+
message,
|
|
22216
|
+
});
|
|
22217
|
+
};
|
|
22218
|
+
try {
|
|
22219
|
+
await stopService(deps);
|
|
22220
|
+
deps.copyFile(ctx.backupPath, ctx.bundlePath);
|
|
22221
|
+
await deps.platform.start();
|
|
22222
|
+
await confirmHealthy(deps, ctx.port, ctx.certPem, ctx.fp, ctx.previousVersion, ctx.budget);
|
|
22223
|
+
}
|
|
22224
|
+
catch (err) {
|
|
22225
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
22226
|
+
deps.print(`rollback failed: ${why}; the new bundle failed first (${ctx.reason}). ` +
|
|
22227
|
+
`${ctx.backupPath} holds the previous bundle: copy it over ${ctx.bundlePath} if it is not ` +
|
|
22228
|
+
"already there, then run ac-agent start");
|
|
22229
|
+
record("failed", `${ctx.reason}; rollback failed: ${why}`);
|
|
22230
|
+
throw new Error(`install did not take and the rollback failed: ${why}`);
|
|
22231
|
+
}
|
|
22232
|
+
const serving = ctx.previousVersion ?? "the previous bundle";
|
|
22233
|
+
deps.print(`rolled back: now serving ${serving} (${ctx.reason})`);
|
|
22234
|
+
record("rolled-back", ctx.reason);
|
|
22235
|
+
throw new Error(`install did not take: ${deps.version} never came up; rolled back to ${serving}`);
|
|
22079
22236
|
}
|
|
22080
22237
|
async function status(deps, now = () => new Date()) {
|
|
22081
22238
|
const loaded = loadConfig(deps.configDir, {
|
|
@@ -22113,6 +22270,7 @@ async function status(deps, now = () => new Date()) {
|
|
|
22113
22270
|
grants,
|
|
22114
22271
|
virtualized: virtualization.virtualized,
|
|
22115
22272
|
lanAddress: deps.ips[0] ?? null,
|
|
22273
|
+
update: readUpdateRecord(deps),
|
|
22116
22274
|
};
|
|
22117
22275
|
}
|
|
22118
22276
|
/** Generates a one-time pairing code (#48): refuses when already paired unless `force` (which is how
|
|
@@ -22609,6 +22767,8 @@ async function dispatch(argv, io) {
|
|
|
22609
22767
|
port: { type: "string" },
|
|
22610
22768
|
name: { type: "string" },
|
|
22611
22769
|
"repos-root": { type: "string" },
|
|
22770
|
+
// #659: set only by the agent's own update route -- see ServiceDeps.updateMode.
|
|
22771
|
+
update: { type: "boolean", default: false },
|
|
22612
22772
|
},
|
|
22613
22773
|
allowPositionals: false,
|
|
22614
22774
|
});
|
|
@@ -22619,6 +22779,7 @@ async function dispatch(argv, io) {
|
|
|
22619
22779
|
port: values.port !== undefined ? Number.parseInt(values.port, 10) : undefined,
|
|
22620
22780
|
name: values.name,
|
|
22621
22781
|
reposRoot,
|
|
22782
|
+
update: values.update ?? false,
|
|
22622
22783
|
});
|
|
22623
22784
|
return 0;
|
|
22624
22785
|
}
|
|
@@ -22661,9 +22822,19 @@ async function dispatch(argv, io) {
|
|
|
22661
22822
|
case "publish-adopt":
|
|
22662
22823
|
await io.publishAdopt();
|
|
22663
22824
|
return 0;
|
|
22664
|
-
case "version":
|
|
22665
|
-
|
|
22825
|
+
case "version": {
|
|
22826
|
+
const { values } = parseArgs({
|
|
22827
|
+
args: rest,
|
|
22828
|
+
options: { json: { type: "boolean", default: false } },
|
|
22829
|
+
allowPositionals: false,
|
|
22830
|
+
});
|
|
22831
|
+
// #659: `--json` is how the update route reads a staged bundle's API version without
|
|
22832
|
+
// starting it.
|
|
22833
|
+
io.print(values.json
|
|
22834
|
+
? JSON.stringify({ version: packageJson.version, apiVersion: AGENT_API_VERSION })
|
|
22835
|
+
: packageJson.version);
|
|
22666
22836
|
return 0;
|
|
22837
|
+
}
|
|
22667
22838
|
default:
|
|
22668
22839
|
io.printError(USAGE);
|
|
22669
22840
|
return 1;
|
|
@@ -22854,6 +23025,13 @@ async function buildPlatformOps(port, unitPath, launchScriptPath, configDir, sta
|
|
|
22854
23025
|
function systemdUnitPath(homedir) {
|
|
22855
23026
|
return path.posix.join(homedir, ".config", "systemd", "user", "ac-agent.service");
|
|
22856
23027
|
}
|
|
23028
|
+
/** A `/v1/health` answer as `install`/`status` read it: the HTTP status, plus the reported version
|
|
23029
|
+
* when the body carries one (#659) -- that version is what tells a restarted new bundle from the old
|
|
23030
|
+
* process still answering on the port. */
|
|
23031
|
+
function healthResultFrom(ok, body) {
|
|
23032
|
+
const version = typeof body === "object" && body !== null ? body.version : undefined;
|
|
23033
|
+
return typeof version === "string" ? { ok, version } : { ok };
|
|
23034
|
+
}
|
|
22857
23035
|
async function buildServiceDeps(overrides = {}) {
|
|
22858
23036
|
const os = await import('node:os');
|
|
22859
23037
|
const fs = await import('node:fs');
|
|
@@ -22886,6 +23064,7 @@ async function buildServiceDeps(overrides = {}) {
|
|
|
22886
23064
|
},
|
|
22887
23065
|
readFile,
|
|
22888
23066
|
writeFile: (p, content, mode) => fs.writeFileSync(p, content, mode !== undefined ? { mode } : undefined),
|
|
23067
|
+
renameFile: (from, to) => fs.renameSync(from, to),
|
|
22889
23068
|
removeFile: (p) => {
|
|
22890
23069
|
try {
|
|
22891
23070
|
fs.unlinkSync(p);
|
|
@@ -22912,6 +23091,7 @@ async function buildServiceDeps(overrides = {}) {
|
|
|
22912
23091
|
machineNameOverride: overrides.name,
|
|
22913
23092
|
portOverride: overrides.port,
|
|
22914
23093
|
reposRootOverride: overrides.reposRoot,
|
|
23094
|
+
updateMode: overrides.update ?? false,
|
|
22915
23095
|
env: process.env,
|
|
22916
23096
|
osPlatform: process.platform,
|
|
22917
23097
|
readdir: (p) => fs.readdirSync(p),
|
|
@@ -22939,7 +23119,14 @@ async function buildServiceDeps(overrides = {}) {
|
|
|
22939
23119
|
const res = await undiciFetch(`https://127.0.0.1:${healthPort}/v1/health`, {
|
|
22940
23120
|
dispatcher: agent,
|
|
22941
23121
|
});
|
|
22942
|
-
|
|
23122
|
+
let body;
|
|
23123
|
+
try {
|
|
23124
|
+
body = await res.json();
|
|
23125
|
+
}
|
|
23126
|
+
catch {
|
|
23127
|
+
// an unparseable body still counts by its status alone, as before
|
|
23128
|
+
}
|
|
23129
|
+
return healthResultFrom(res.ok, body);
|
|
22943
23130
|
}
|
|
22944
23131
|
catch {
|
|
22945
23132
|
return null;
|
|
@@ -74959,4 +75146,4 @@ var tunnel = /*#__PURE__*/Object.freeze({
|
|
|
74959
75146
|
registerTunnelVerb: registerTunnelVerb
|
|
74960
75147
|
});
|
|
74961
75148
|
|
|
74962
|
-
export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, isEntryPoint, main, realExists };
|
|
75149
|
+
export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rackbops/ac-agent",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.48",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.",
|