@forgezero/agent 0.1.31 → 0.1.32

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/dist/fz-agent.js CHANGED
@@ -3,8 +3,8 @@
3
3
 
4
4
  // src/index.ts
5
5
  import { randomBytes as randomBytes5 } from "crypto";
6
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync8, existsSync as existsSync13, mkdirSync as mkdirSync9, chmodSync as chmodSync12 } from "fs";
7
- import { dirname as dirname7, join as join5 } from "path";
6
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync8, existsSync as existsSync13, mkdirSync as mkdirSync9, chmodSync as chmodSync12 } from "fs";
7
+ import { dirname as dirname7, join as join6 } from "path";
8
8
 
9
9
  // ../access/dist/security.js
10
10
  var HEX = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, "0"));
@@ -7581,8 +7581,11 @@ function materializeWarpMdm(options) {
7581
7581
  import { createHash as createHash3, timingSafeEqual, randomUUID as randomUUID2 } from "crypto";
7582
7582
  import {
7583
7583
  chmodSync as chmodSync9,
7584
+ closeSync,
7584
7585
  existsSync as existsSync10,
7586
+ fsyncSync,
7585
7587
  mkdirSync as mkdirSync6,
7588
+ openSync,
7586
7589
  readFileSync as readFileSync6,
7587
7590
  readlinkSync,
7588
7591
  renameSync as renameSync4,
@@ -7596,6 +7599,25 @@ var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
7596
7599
  var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
7597
7600
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
7598
7601
  var REGISTRY = "registry.npmjs.org";
7602
+ var syncPath = (path) => {
7603
+ const descriptor = openSync(path, "r");
7604
+ try {
7605
+ fsyncSync(descriptor);
7606
+ } finally {
7607
+ closeSync(descriptor);
7608
+ }
7609
+ };
7610
+ var syncReleaseDirectory = (directory) => {
7611
+ for (const path of [
7612
+ join4(directory, "package.json"),
7613
+ join4(directory, "dist", "fz-agent.js"),
7614
+ join4(directory, "dist", "fz.js"),
7615
+ join4(directory, "dist"),
7616
+ directory,
7617
+ dirname4(directory)
7618
+ ])
7619
+ syncPath(path);
7620
+ };
7599
7621
  function validateAgentRelease(release) {
7600
7622
  if (release?.package !== "@forgezero/agent")
7601
7623
  throw new Error("agent update package is fixed");
@@ -7714,16 +7736,24 @@ async function stageAgentRelease(releaseInput, options) {
7714
7736
  ]
7715
7737
  }, "agent update extraction");
7716
7738
  await validateReleaseDirectory(unpacked, release, run);
7717
- if (!existsSync10(finalDirectory))
7739
+ if (!existsSync10(finalDirectory)) {
7718
7740
  renameSync4(unpacked, finalDirectory);
7719
- else
7741
+ syncReleaseDirectory(finalDirectory);
7742
+ } else
7720
7743
  await validateReleaseDirectory(finalDirectory, release, run);
7721
7744
  if (!existsSync10(currentLink)) {
7722
7745
  throw new Error("agent update requires an active immutable release to roll back to");
7723
7746
  }
7724
7747
  const previousTarget = readlinkSync(currentLink);
7748
+ if (previousTarget !== join4("versions", options.currentVersion)) {
7749
+ throw new Error("agent update current release does not match the running version");
7750
+ }
7751
+ if (!existsSync10(join4(root, previousTarget))) {
7752
+ throw new Error("agent update rollback release is missing");
7753
+ }
7725
7754
  return {
7726
7755
  version: release.version,
7756
+ fromVersion: options.currentVersion,
7727
7757
  directory: finalDirectory,
7728
7758
  previousTarget,
7729
7759
  nextTarget: join4("versions", release.version),
@@ -7738,6 +7768,7 @@ function selectAgentRelease(staged) {
7738
7768
  try {
7739
7769
  symlinkSync2(staged.nextTarget, next);
7740
7770
  renameSync4(next, staged.currentLink);
7771
+ syncPath(dirname4(staged.currentLink));
7741
7772
  } finally {
7742
7773
  rmSync2(next, { force: true });
7743
7774
  }
@@ -7747,24 +7778,187 @@ function restoreAgentRelease(staged) {
7747
7778
  try {
7748
7779
  symlinkSync2(staged.previousTarget, next);
7749
7780
  renameSync4(next, staged.currentLink);
7781
+ syncPath(dirname4(staged.currentLink));
7750
7782
  } finally {
7751
7783
  rmSync2(next, { force: true });
7752
7784
  }
7753
7785
  }
7754
7786
 
7755
7787
  // src/agent-update-helper.ts
7756
- import { chmodSync as chmodSync10, existsSync as existsSync11, mkdirSync as mkdirSync7, renameSync as renameSync5, unlinkSync as unlinkSync9, writeFileSync as writeFileSync7 } from "fs";
7788
+ import { randomUUID as randomUUID3 } from "crypto";
7789
+ import {
7790
+ chmodSync as chmodSync10,
7791
+ closeSync as closeSync2,
7792
+ existsSync as existsSync11,
7793
+ fsyncSync as fsyncSync2,
7794
+ mkdirSync as mkdirSync7,
7795
+ openSync as openSync2,
7796
+ readFileSync as readFileSync7,
7797
+ renameSync as renameSync5,
7798
+ rmSync as rmSync3,
7799
+ unlinkSync as unlinkSync9,
7800
+ writeFileSync as writeFileSync7
7801
+ } from "fs";
7757
7802
  import { connect as connect5, createServer as createServer6 } from "net";
7758
- import { dirname as dirname5 } from "path";
7803
+ import { dirname as dirname5, join as join5, resolve as resolve3 } from "path";
7759
7804
  var AGENT_UPDATE_GROUP = "forgezero-update";
7760
7805
  var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
7761
- var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update.json";
7806
+ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
7807
+ var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
7762
7808
  var MAX_REQUEST_BYTES5 = 8 * 1024;
7763
7809
  var COMPUTE_HELPER_UNITS = [
7764
7810
  "forgezero-deploy-runner.service",
7765
7811
  "forgezero-lifecycle-helper.service",
7766
7812
  "forgezero-software-helper.service"
7767
7813
  ];
7814
+ var VERSION2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
7815
+ var ATTEMPT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7816
+ var REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
7817
+ var MAX_REASON_BYTES = 512;
7818
+ var UPDATE_RETRY_BASE_MS = 5 * 60000;
7819
+ var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
7820
+ var boundedMessage = (value) => {
7821
+ let message = value.replace(/[\r\n]+/g, " ").trim();
7822
+ while (Buffer.byteLength(message, "utf8") > MAX_REASON_BYTES)
7823
+ message = message.slice(0, -1);
7824
+ return message;
7825
+ };
7826
+ var reason = (code, message) => ({
7827
+ code: REASON_CODE.test(code) ? code : "UPDATE_FAILED",
7828
+ message: boundedMessage(message) || "Agent update failed"
7829
+ });
7830
+ var validTime = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
7831
+ function validateReceipt(value) {
7832
+ if (!value || typeof value !== "object")
7833
+ throw new Error("Agent update receipt is malformed");
7834
+ const receipt = value;
7835
+ if (!receipt.attemptId || !ATTEMPT_ID.test(receipt.attemptId))
7836
+ throw new Error("Agent update attempt ID is invalid");
7837
+ if (!receipt.fromVersion || !VERSION2.test(receipt.fromVersion))
7838
+ throw new Error("Agent update source version is invalid");
7839
+ if (!receipt.targetVersion || !VERSION2.test(receipt.targetVersion))
7840
+ throw new Error("Agent update target version is invalid");
7841
+ if (!["activating", "active", "rolled-back", "failed"].includes(receipt.outcome ?? "")) {
7842
+ throw new Error("Agent update outcome is invalid");
7843
+ }
7844
+ if (!validTime(receipt.startedAtTs) || !validTime(receipt.updatedAtTs)) {
7845
+ throw new Error("Agent update timestamps are invalid");
7846
+ }
7847
+ if (receipt.retryAfterTs !== undefined && !validTime(receipt.retryAfterTs)) {
7848
+ throw new Error("Agent update retry timestamp is invalid");
7849
+ }
7850
+ if (receipt.rollbackHealthy !== undefined && typeof receipt.rollbackHealthy !== "boolean") {
7851
+ throw new Error("Agent update rollback health is invalid");
7852
+ }
7853
+ if (receipt.reason && (!REASON_CODE.test(receipt.reason.code) || typeof receipt.reason.message !== "string" || Buffer.byteLength(receipt.reason.message, "utf8") > MAX_REASON_BYTES))
7854
+ throw new Error("Agent update failure reason is invalid");
7855
+ return {
7856
+ attemptId: receipt.attemptId,
7857
+ fromVersion: receipt.fromVersion,
7858
+ targetVersion: receipt.targetVersion,
7859
+ outcome: receipt.outcome,
7860
+ startedAtTs: receipt.startedAtTs,
7861
+ updatedAtTs: receipt.updatedAtTs,
7862
+ ...receipt.retryAfterTs === undefined ? {} : { retryAfterTs: receipt.retryAfterTs },
7863
+ ...receipt.rollbackHealthy === undefined ? {} : { rollbackHealthy: receipt.rollbackHealthy },
7864
+ ...receipt.reason === undefined ? {} : { reason: receipt.reason }
7865
+ };
7866
+ }
7867
+ function validateJournal(value, root) {
7868
+ if (!value || typeof value !== "object")
7869
+ throw new Error("Agent update journal is malformed");
7870
+ const legacy = value;
7871
+ if (legacy.schemaVersion === undefined) {
7872
+ if (typeof legacy.version === "string" && VERSION2.test(legacy.version) && legacy.outcome === "active" && validTime(legacy.updatedAtTs) && Object.keys(value).every((key) => ["version", "outcome", "updatedAtTs"].includes(key)))
7873
+ return;
7874
+ throw new Error("Agent update legacy receipt is malformed");
7875
+ }
7876
+ const journal = value;
7877
+ const receipt = validateReceipt(journal);
7878
+ if (journal.schemaVersion !== 1)
7879
+ throw new Error("Agent update journal schema is unsupported");
7880
+ if (journal.target !== "compute" && journal.target !== "metal")
7881
+ throw new Error("Agent update target is invalid");
7882
+ if (!Number.isSafeInteger(journal.failureCount) || (journal.failureCount ?? -1) < 0) {
7883
+ throw new Error("Agent update failure count is invalid");
7884
+ }
7885
+ const releaseRoot = resolve3(root);
7886
+ if (journal.currentLink !== join5(releaseRoot, "current"))
7887
+ throw new Error("Agent update current link is invalid");
7888
+ if (journal.previousTarget !== join5("versions", receipt.fromVersion)) {
7889
+ throw new Error("Agent update rollback target is invalid");
7890
+ }
7891
+ if (journal.nextTarget !== join5("versions", receipt.targetVersion)) {
7892
+ throw new Error("Agent update next target is invalid");
7893
+ }
7894
+ return journal;
7895
+ }
7896
+ function readJournal(path, root) {
7897
+ if (!existsSync11(path))
7898
+ return;
7899
+ return validateJournal(JSON.parse(readFileSync7(path, "utf8")), root);
7900
+ }
7901
+ function writeAtomic(path, value, mode) {
7902
+ mkdirSync7(dirname5(path), { recursive: true, mode: 493 });
7903
+ const next = `${path}.${randomUUID3()}.next`;
7904
+ let file;
7905
+ try {
7906
+ file = openSync2(next, "wx", mode);
7907
+ writeFileSync7(file, `${JSON.stringify(value)}
7908
+ `);
7909
+ fsyncSync2(file);
7910
+ closeSync2(file);
7911
+ file = undefined;
7912
+ renameSync5(next, path);
7913
+ const directory = openSync2(dirname5(path), "r");
7914
+ try {
7915
+ fsyncSync2(directory);
7916
+ } finally {
7917
+ closeSync2(directory);
7918
+ }
7919
+ } finally {
7920
+ if (file !== undefined)
7921
+ closeSync2(file);
7922
+ rmSync3(next, { force: true });
7923
+ }
7924
+ }
7925
+ var publicReceipt = (journal) => {
7926
+ const {
7927
+ attemptId,
7928
+ fromVersion,
7929
+ targetVersion,
7930
+ outcome,
7931
+ startedAtTs,
7932
+ updatedAtTs,
7933
+ retryAfterTs,
7934
+ rollbackHealthy,
7935
+ reason: failureReason
7936
+ } = journal;
7937
+ return {
7938
+ attemptId,
7939
+ fromVersion,
7940
+ targetVersion,
7941
+ outcome,
7942
+ startedAtTs,
7943
+ updatedAtTs,
7944
+ ...retryAfterTs === undefined ? {} : { retryAfterTs },
7945
+ ...rollbackHealthy === undefined ? {} : { rollbackHealthy },
7946
+ ...failureReason === undefined ? {} : { reason: failureReason }
7947
+ };
7948
+ };
7949
+ function writeUpdateState(journalPath, receiptPath, journal) {
7950
+ writeAtomic(journalPath, journal, 384);
7951
+ writeAtomic(receiptPath, publicReceipt(journal), 416);
7952
+ }
7953
+ function readAgentUpdateReceipt(path = AGENT_UPDATE_RECEIPT) {
7954
+ try {
7955
+ if (!existsSync11(path))
7956
+ return;
7957
+ return validateReceipt(JSON.parse(readFileSync7(path, "utf8")));
7958
+ } catch {
7959
+ return;
7960
+ }
7961
+ }
7768
7962
  var runCommand = async (input) => {
7769
7963
  const child = Bun.spawn([input.command, ...input.args], {
7770
7964
  cwd: input.cwd,
@@ -7780,8 +7974,27 @@ var runCommand = async (input) => {
7780
7974
  return { exitCode, output: `${stdout}${stderr}` };
7781
7975
  };
7782
7976
  var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
7977
+ var retryAfter = (now, failures) => now + Math.min(UPDATE_RETRY_MAX_MS, UPDATE_RETRY_BASE_MS * 2 ** Math.min(16, Math.max(0, failures - 1)));
7978
+ var stagedFromJournal = (journal, root) => ({
7979
+ version: journal.targetVersion,
7980
+ fromVersion: journal.fromVersion,
7981
+ directory: join5(resolve3(root), journal.nextTarget),
7982
+ previousTarget: journal.previousTarget,
7983
+ nextTarget: journal.nextTarget,
7984
+ currentLink: journal.currentLink
7985
+ });
7986
+ var restartAgent = async (target, run) => {
7987
+ const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
7988
+ for (const unit of helpers)
7989
+ await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
7990
+ const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
7991
+ if (!await runOk(run, "/usr/bin/systemctl", ["restart", service])) {
7992
+ throw new Error(`systemd could not restart ${service}`);
7993
+ }
7994
+ };
7995
+ var targetProbe = (target, run) => target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]);
7783
7996
  function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
7784
- return new Promise((resolve3) => {
7997
+ return new Promise((resolve4) => {
7785
7998
  const socket = connect5(socketPath);
7786
7999
  let settled = false;
7787
8000
  let buffer = "";
@@ -7791,7 +8004,7 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
7791
8004
  settled = true;
7792
8005
  clearTimeout(timer);
7793
8006
  socket.destroy();
7794
- resolve3(value);
8007
+ resolve4(value);
7795
8008
  };
7796
8009
  const timer = setTimeout(() => finish(false), timeoutMs);
7797
8010
  socket.on("connect", () => socket.write(`{"op":"identity"}
@@ -7815,55 +8028,136 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
7815
8028
  async function activateAgentRelease(staged, options = {}) {
7816
8029
  const run = options.run ?? runCommand;
7817
8030
  const target = options.target ?? "compute";
7818
- const probe = options.probe ?? (target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]));
7819
- const restart = async () => {
7820
- const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
7821
- for (const unit of helpers) {
7822
- await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
7823
- }
7824
- const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
7825
- const restarted = await runOk(run, "/usr/bin/systemctl", ["restart", service]);
7826
- if (!restarted)
7827
- throw new Error(`systemd could not restart ${service}`);
8031
+ const probe = options.probe ?? targetProbe(target, run);
8032
+ const now = options.now ?? Date.now;
8033
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
8034
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
8035
+ const previous = readJournal(journalPath, dirname5(staged.currentLink));
8036
+ const attemptId = options.attemptId ?? randomUUID3();
8037
+ if (!ATTEMPT_ID.test(attemptId))
8038
+ throw new Error("Agent update attempt ID is invalid");
8039
+ const startedAtTs = now();
8040
+ const failureCount = previous?.targetVersion === staged.version ? previous.failureCount : 0;
8041
+ let journal = {
8042
+ schemaVersion: 1,
8043
+ attemptId,
8044
+ target,
8045
+ fromVersion: staged.fromVersion,
8046
+ targetVersion: staged.version,
8047
+ outcome: "activating",
8048
+ startedAtTs,
8049
+ updatedAtTs: startedAtTs,
8050
+ currentLink: staged.currentLink,
8051
+ previousTarget: staged.previousTarget,
8052
+ nextTarget: staged.nextTarget,
8053
+ failureCount
7828
8054
  };
7829
- let selected = false;
8055
+ writeUpdateState(journalPath, receiptPath, journal);
8056
+ let selectionAttempted = false;
7830
8057
  try {
8058
+ selectionAttempted = true;
7831
8059
  selectAgentRelease(staged);
7832
- selected = true;
7833
- await restart();
8060
+ await restartAgent(target, run);
7834
8061
  if (!await probe())
7835
8062
  throw new Error("the replacement Agent did not answer its retained Vault socket");
7836
- const receipt = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
7837
- mkdirSync7(dirname5(receipt), { recursive: true, mode: 493 });
7838
- const next = `${receipt}.next`;
7839
- writeFileSync7(next, JSON.stringify({
7840
- version: staged.version,
7841
- outcome: "active",
7842
- updatedAtTs: (options.now ?? Date.now)()
7843
- }) + `
7844
- `, { mode: 420 });
7845
- renameSync5(next, receipt);
8063
+ journal = { ...journal, outcome: "active", updatedAtTs: now(), rollbackHealthy: undefined };
8064
+ writeUpdateState(journalPath, receiptPath, journal);
7846
8065
  run({
7847
8066
  command: "/usr/bin/systemctl",
7848
8067
  args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
7849
8068
  });
7850
8069
  return { ok: true, version: staged.version };
7851
8070
  } catch (cause) {
7852
- const reason = cause instanceof Error ? cause.message : String(cause);
7853
- if (selected) {
7854
- restoreAgentRelease(staged);
7855
- await restart();
8071
+ const message = cause instanceof Error ? cause.message : String(cause);
8072
+ let rollbackHealthy = false;
8073
+ let restored = false;
8074
+ if (selectionAttempted) {
8075
+ try {
8076
+ restoreAgentRelease(staged);
8077
+ restored = true;
8078
+ await restartAgent(target, run);
8079
+ rollbackHealthy = await probe();
8080
+ } catch {
8081
+ rollbackHealthy = false;
8082
+ }
7856
8083
  }
7857
- return { ok: false, rolledBack: selected, reason };
8084
+ const failures = failureCount + 1;
8085
+ const updatedAtTs = now();
8086
+ journal = {
8087
+ ...journal,
8088
+ outcome: rollbackHealthy ? "rolled-back" : "failed",
8089
+ updatedAtTs,
8090
+ retryAfterTs: retryAfter(updatedAtTs, failures),
8091
+ rollbackHealthy,
8092
+ reason: reason(rollbackHealthy ? "REPLACEMENT_UNHEALTHY" : "ROLLBACK_UNHEALTHY", rollbackHealthy ? message : `${message}; the restored Agent did not pass its health probe`),
8093
+ failureCount: failures
8094
+ };
8095
+ writeUpdateState(journalPath, receiptPath, journal);
8096
+ return { ok: false, rolledBack: restored, rollbackHealthy, reason: message };
7858
8097
  }
7859
8098
  }
8099
+ async function recoverInterruptedAgentUpdate(options = {}) {
8100
+ const root = resolve3(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
8101
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
8102
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
8103
+ const journal = readJournal(journalPath, root);
8104
+ if (!journal)
8105
+ return;
8106
+ if (journal.outcome !== "activating") {
8107
+ writeAtomic(receiptPath, publicReceipt(journal), 416);
8108
+ return publicReceipt(journal);
8109
+ }
8110
+ const staged = stagedFromJournal(journal, root);
8111
+ if (!existsSync11(join5(root, journal.previousTarget))) {
8112
+ throw new Error("Agent update rollback release is missing");
8113
+ }
8114
+ const run = options.run ?? runCommand;
8115
+ const probe = options.probe ?? targetProbe(journal.target, run);
8116
+ restoreAgentRelease(staged);
8117
+ let rollbackHealthy = false;
8118
+ let failureMessage = "activation was interrupted before its health verdict became durable";
8119
+ try {
8120
+ await restartAgent(journal.target, run);
8121
+ rollbackHealthy = await probe();
8122
+ } catch (cause) {
8123
+ failureMessage = `${failureMessage}; ${cause instanceof Error ? cause.message : String(cause)}`;
8124
+ }
8125
+ const failures = journal.failureCount + 1;
8126
+ const updatedAtTs = (options.now ?? Date.now)();
8127
+ const recovered = {
8128
+ ...journal,
8129
+ outcome: rollbackHealthy ? "rolled-back" : "failed",
8130
+ updatedAtTs,
8131
+ retryAfterTs: retryAfter(updatedAtTs, failures),
8132
+ rollbackHealthy,
8133
+ reason: reason(rollbackHealthy ? "ACTIVATION_INTERRUPTED" : "ROLLBACK_UNHEALTHY", failureMessage),
8134
+ failureCount: failures
8135
+ };
8136
+ writeUpdateState(journalPath, receiptPath, recovered);
8137
+ return readAgentUpdateReceipt(receiptPath);
8138
+ }
7860
8139
  function startAgentUpdateHelper(options = {}) {
7861
8140
  const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
7862
8141
  if (existsSync11(socketPath))
7863
8142
  unlinkSync9(socketPath);
7864
8143
  mkdirSync7(dirname5(socketPath), { recursive: true, mode: 488 });
7865
8144
  const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
7866
- const activate = options.activate ?? ((staged, target) => activateAgentRelease(staged, { target }));
8145
+ const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
8146
+ const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
8147
+ const releaseRoot = options.root ?? DEFAULT_AGENT_RELEASE_ROOT;
8148
+ const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, { target, attemptId, journalPath, receiptPath, now: options.now }));
8149
+ let busy = true;
8150
+ let blocked;
8151
+ (options.recover ?? (() => recoverInterruptedAgentUpdate({
8152
+ root: releaseRoot,
8153
+ journalPath,
8154
+ receiptPath,
8155
+ now: options.now
8156
+ })))().catch((cause) => {
8157
+ blocked = cause instanceof Error ? cause.message : String(cause);
8158
+ }).finally(() => {
8159
+ busy = false;
8160
+ });
7867
8161
  const server = createServer6((socket) => {
7868
8162
  let buffer = "";
7869
8163
  socket.on("data", (chunk) => {
@@ -7879,22 +8173,42 @@ function startAgentUpdateHelper(options = {}) {
7879
8173
  return;
7880
8174
  const line = buffer.slice(0, newline);
7881
8175
  buffer = "";
8176
+ let ownsBusy = false;
7882
8177
  Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
8178
+ if (blocked)
8179
+ throw new Error(`update journal needs operator recovery: ${blocked}`);
8180
+ if (busy)
8181
+ throw new Error("another Agent update or recovery is already active");
7883
8182
  if (request.op !== "apply")
7884
8183
  throw new Error("unknown update operation");
7885
8184
  if (request.target !== "compute" && request.target !== "metal") {
7886
8185
  throw new Error("agent update target is invalid");
7887
8186
  }
8187
+ const attemptId = request.attemptId ?? randomUUID3();
8188
+ if (!ATTEMPT_ID.test(attemptId))
8189
+ throw new Error("Agent update attempt ID is invalid");
8190
+ const prior = readJournal(journalPath, releaseRoot);
8191
+ const now = (options.now ?? Date.now)();
8192
+ if (prior?.targetVersion === request.release.version && (prior.outcome === "rolled-back" || prior.outcome === "failed") && (prior.retryAfterTs ?? 0) > now)
8193
+ throw new Error(`Agent update ${request.release.version} is quarantined until ${prior.retryAfterTs}`);
8194
+ busy = true;
8195
+ ownsBusy = true;
7888
8196
  const staged = await stageAgentRelease(request.release, {
7889
8197
  currentVersion: request.currentVersion,
7890
- root: options.root ?? DEFAULT_AGENT_RELEASE_ROOT
8198
+ root: releaseRoot
7891
8199
  });
7892
- const response = { ok: true, status: "staged", version: staged.version };
8200
+ const response = { ok: true, status: "staged", version: staged.version, attemptId };
7893
8201
  socket.end(`${JSON.stringify(response)}
7894
- `, () => {
7895
- setTimer(() => void activate(staged, request.target), 100);
7896
- });
8202
+ `);
8203
+ setTimer(() => void activate(staged, request.target, attemptId).catch((cause) => {
8204
+ blocked = cause instanceof Error ? cause.message : String(cause);
8205
+ }).finally(() => {
8206
+ busy = false;
8207
+ }), 100);
8208
+ ownsBusy = false;
7897
8209
  }).catch((cause) => {
8210
+ if (ownsBusy)
8211
+ busy = false;
7898
8212
  const response = {
7899
8213
  ok: false,
7900
8214
  error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
@@ -7909,7 +8223,7 @@ function startAgentUpdateHelper(options = {}) {
7909
8223
  return server;
7910
8224
  }
7911
8225
  function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
7912
- return new Promise((resolve3, reject) => {
8226
+ return new Promise((resolve4, reject) => {
7913
8227
  const socket = connect5(socketPath, () => socket.write(`${JSON.stringify(request)}
7914
8228
  `));
7915
8229
  let buffer = "";
@@ -7925,7 +8239,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
7925
8239
  return;
7926
8240
  socket.end();
7927
8241
  try {
7928
- resolve3(JSON.parse(buffer.slice(0, newline)));
8242
+ resolve4(JSON.parse(buffer.slice(0, newline)));
7929
8243
  } catch (cause) {
7930
8244
  reject(cause);
7931
8245
  }
@@ -7935,14 +8249,14 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
7935
8249
  }
7936
8250
 
7937
8251
  // src/agent-heartbeat.ts
7938
- import { readFileSync as readFileSync7 } from "fs";
8252
+ import { readFileSync as readFileSync8 } from "fs";
7939
8253
 
7940
8254
  // src/version.ts
7941
- var VERSION2 = "0.1.31";
8255
+ var VERSION3 = "0.1.32";
7942
8256
 
7943
8257
  // src/agent-heartbeat.ts
7944
8258
  var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
7945
- function observeAgentHost(version = VERSION2, mode = "enrolled", osRelease = readFileSync7("/etc/os-release", "utf8"), architecture = process.arch) {
8259
+ function observeAgentHost(version = VERSION3, mode = "enrolled", osRelease = readFileSync8("/etc/os-release", "utf8"), architecture = process.arch) {
7946
8260
  const values = Object.fromEntries(osRelease.split(`
7947
8261
  `).flatMap((line) => {
7948
8262
  const separator = line.indexOf("=");
@@ -7956,23 +8270,51 @@ function observeAgentHost(version = VERSION2, mode = "enrolled", osRelease = rea
7956
8270
  };
7957
8271
  }
7958
8272
  async function heartbeatAgentOnce(options) {
7959
- const observation = (options.observation ?? (() => observeAgentHost(options.version ?? VERSION2, options.mode)))();
8273
+ let observation = (options.observation ?? (() => observeAgentHost(options.version ?? VERSION3, options.mode)))();
8274
+ const update = observation.update ?? readAgentUpdateReceipt(options.receiptPath ?? AGENT_UPDATE_RECEIPT);
8275
+ if (update)
8276
+ observation = { ...observation, update };
7960
8277
  const response = await postSignedNode(options, "v1/node/heartbeat", observation);
7961
- if (response.desiredAgentRelease) {
7962
- const release = validateAgentRelease(response.desiredAgentRelease);
8278
+ const desired = response.desiredAgentUpdate ?? (response.desiredAgentRelease ? {
8279
+ attemptId: `legacy:${response.desiredAgentRelease.version}`,
8280
+ leaseExpiresAtTs: Number.MAX_SAFE_INTEGER,
8281
+ release: response.desiredAgentRelease
8282
+ } : undefined);
8283
+ if (desired) {
8284
+ const release = validateAgentRelease(desired.release);
8285
+ const now = (options.now ?? Date.now)();
8286
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(desired.attemptId)) {
8287
+ throw new Error("agent update attempt ID is invalid");
8288
+ }
8289
+ if (!Number.isSafeInteger(desired.leaseExpiresAtTs) || desired.leaseExpiresAtTs <= now) {
8290
+ options.onEvent?.("update-lease-expired", { attemptId: desired.attemptId, to: release.version });
8291
+ return response;
8292
+ }
7963
8293
  if (compareVersions(release.version, observation.version) > 0) {
8294
+ if (update?.targetVersion === release.version && (update.outcome === "rolled-back" || update.outcome === "failed") && (update.retryAfterTs ?? 0) > now) {
8295
+ options.onEvent?.("update-quarantined", {
8296
+ attemptId: desired.attemptId,
8297
+ to: release.version,
8298
+ retryAfterTs: update.retryAfterTs
8299
+ });
8300
+ return response;
8301
+ }
7964
8302
  let prepared = false;
7965
8303
  try {
7966
8304
  await options.prepareUpdate?.(release);
7967
8305
  prepared = true;
7968
- const applied = await (options.applyUpdate ?? ((next, current) => requestAgentUpdate({
8306
+ const applied = await (options.applyUpdate ?? ((next, current, attemptId) => requestAgentUpdate({
7969
8307
  op: "apply",
7970
8308
  target: options.updateTarget ?? "compute",
7971
8309
  release: next,
7972
- currentVersion: current
7973
- })))(release, observation.version);
8310
+ currentVersion: current,
8311
+ attemptId
8312
+ })))(release, observation.version, desired.attemptId);
7974
8313
  if (!applied.ok)
7975
8314
  throw new Error(`agent update refused: ${applied.error.message}`);
8315
+ if (applied.attemptId !== desired.attemptId) {
8316
+ throw new Error("agent update helper returned the wrong rollout attempt");
8317
+ }
7976
8318
  options.onEvent?.("update-staged", { from: observation.version, to: release.version });
7977
8319
  } catch (cause) {
7978
8320
  if (prepared)
@@ -8092,7 +8434,7 @@ function startSoftwareHelper(options = {}) {
8092
8434
  }
8093
8435
  function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
8094
8436
  validateSoftwareRequirements(requirements);
8095
- return new Promise((resolve3, reject) => {
8437
+ return new Promise((resolve4, reject) => {
8096
8438
  const socket = connect6(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
8097
8439
  `));
8098
8440
  let buffer = "";
@@ -8111,7 +8453,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
8111
8453
  const response = JSON.parse(buffer.slice(0, newline));
8112
8454
  if (!response.ok || !response.results)
8113
8455
  throw new Error(response.error?.message ?? "software helper refused the request");
8114
- resolve3(response.results);
8456
+ resolve4(response.results);
8115
8457
  } catch (cause) {
8116
8458
  reject(cause);
8117
8459
  }
@@ -8123,7 +8465,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
8123
8465
  // src/index.ts
8124
8466
  function loadOrCreateSeed(path) {
8125
8467
  if (existsSync13(path)) {
8126
- const seed2 = new Uint8Array(Buffer.from(readFileSync8(path, "utf8").trim(), "base64url"));
8468
+ const seed2 = new Uint8Array(Buffer.from(readFileSync9(path, "utf8").trim(), "base64url"));
8127
8469
  if (seed2.length < 32) {
8128
8470
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
8129
8471
  }
@@ -8156,7 +8498,7 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
8156
8498
  const path = `${directory}/${name}`;
8157
8499
  if (!existsSync13(path))
8158
8500
  throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
8159
- const seed = new Uint8Array(Buffer.from(readFileSync8(path, "utf8").trim(), "base64url"));
8501
+ const seed = new Uint8Array(Buffer.from(readFileSync9(path, "utf8").trim(), "base64url"));
8160
8502
  if (seed.length < 32)
8161
8503
  throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
8162
8504
  return seed;
@@ -8166,7 +8508,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
8166
8508
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
8167
8509
  if (!/^[A-Za-z0-9_.-]+$/.test(name))
8168
8510
  throw new Error("agent: invalid systemd credential name.");
8169
- const value = readFileSync8(`${directory}/${name}`, "utf8").trim();
8511
+ const value = readFileSync9(`${directory}/${name}`, "utf8").trim();
8170
8512
  if (!value)
8171
8513
  throw new Error(`agent: systemd credential ${name} is empty.`);
8172
8514
  return value;
@@ -8216,7 +8558,7 @@ if (import.meta.main) {
8216
8558
  const args = process.argv.slice(2);
8217
8559
  if (args.includes("--help") || args.includes("-h")) {
8218
8560
  console.log([
8219
- `fz-agent ${VERSION2}`,
8561
+ `fz-agent ${VERSION3}`,
8220
8562
  "",
8221
8563
  "Runs inside managed compute and answers secret requests over a local socket.",
8222
8564
  "It holds no configuration of its own \u2014 everything comes from the",
@@ -8244,7 +8586,7 @@ if (import.meta.main) {
8244
8586
  process.exit(0);
8245
8587
  }
8246
8588
  if (args.includes("--version") || args.includes("-v")) {
8247
- console.log(VERSION2);
8589
+ console.log(VERSION3);
8248
8590
  process.exit(0);
8249
8591
  }
8250
8592
  const command2 = args.find((arg) => !arg.startsWith("-"));
@@ -8274,7 +8616,7 @@ if (import.meta.main) {
8274
8616
  keys: keys2,
8275
8617
  label: process.env.FZ_NODE_LABEL,
8276
8618
  edgeHostname: process.env.FZ_NODE_HOSTNAME,
8277
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync8(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
8619
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync9(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
8278
8620
  privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
8279
8621
  });
8280
8622
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
@@ -8284,7 +8626,7 @@ if (import.meta.main) {
8284
8626
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
8285
8627
  if (!profilePath)
8286
8628
  throw new Error("metal-helper requires --profile=/absolute/path.json");
8287
- const profile2 = JSON.parse(readFileSync8(profilePath, "utf8"));
8629
+ const profile2 = JSON.parse(readFileSync9(profilePath, "utf8"));
8288
8630
  const helper = startMetalHelper({
8289
8631
  profile: profile2,
8290
8632
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
@@ -8356,7 +8698,7 @@ if (import.meta.main) {
8356
8698
  if (stopping)
8357
8699
  return;
8358
8700
  stopping = true;
8359
- await new Promise((resolve3) => helper.close(() => resolve3()));
8701
+ await new Promise((resolve4) => helper.close(() => resolve4()));
8360
8702
  process.exit(0);
8361
8703
  };
8362
8704
  process.on("SIGTERM", () => void stop());
@@ -8375,7 +8717,7 @@ if (import.meta.main) {
8375
8717
  if (stopping)
8376
8718
  return;
8377
8719
  stopping = true;
8378
- await new Promise((resolve3) => helper.close(() => resolve3()));
8720
+ await new Promise((resolve4) => helper.close(() => resolve4()));
8379
8721
  process.exit(0);
8380
8722
  };
8381
8723
  process.on("SIGTERM", () => void stop());
@@ -8402,7 +8744,7 @@ if (import.meta.main) {
8402
8744
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
8403
8745
  if (!profilePath)
8404
8746
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
8405
- const profile2 = JSON.parse(readFileSync8(profilePath, "utf8"));
8747
+ const profile2 = JSON.parse(readFileSync9(profilePath, "utf8"));
8406
8748
  await applyMetalIsolation(profile2);
8407
8749
  console.log("[metal-isolation] host and guest cgroup boundaries active");
8408
8750
  process.exit(0);
@@ -8447,7 +8789,7 @@ if (import.meta.main) {
8447
8789
  if (claimArg !== "-" && !claimArg.startsWith("/")) {
8448
8790
  throw new Error("metal-apply claim path must be absolute");
8449
8791
  }
8450
- const raw = readFileSync8(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
8792
+ const raw = readFileSync9(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
8451
8793
  if (Buffer.byteLength(raw) > 32 * 1024)
8452
8794
  throw new Error("metal-apply claim exceeds 32 KiB");
8453
8795
  const claim = JSON.parse(raw);
@@ -8512,7 +8854,7 @@ if (import.meta.main) {
8512
8854
  const deadline = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 120000));
8513
8855
  const drained = await Promise.race([
8514
8856
  Promise.all([pull.stop(), heartbeat.stop()]).then(() => true),
8515
- new Promise((resolve3) => setTimeout(() => resolve3(false), deadline))
8857
+ new Promise((resolve4) => setTimeout(() => resolve4(false), deadline))
8516
8858
  ]);
8517
8859
  console.log(`[metal-agent] ${signal}: ${drained ? "drained" : "deadline reached; claim left fenced for recovery"}`);
8518
8860
  process.exit(drained ? 0 : 1);
@@ -8556,7 +8898,7 @@ if (import.meta.main) {
8556
8898
  record: (entry) => console.log(`[agent] ${entry.op} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`)
8557
8899
  });
8558
8900
  const { nodeKey, keys, server } = running;
8559
- console.log(`[agent] ${VERSION2} signing as ${nodeKey}`);
8901
+ console.log(`[agent] ${VERSION3} signing as ${nodeKey}`);
8560
8902
  const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
8561
8903
  let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
8562
8904
  const enrolmentCredential = process.env.FZ_ENROL_TOKEN_CREDENTIAL;
@@ -8572,7 +8914,7 @@ if (import.meta.main) {
8572
8914
  keys,
8573
8915
  label: process.env.FZ_NODE_LABEL,
8574
8916
  edgeHostname: process.env.FZ_NODE_HOSTNAME,
8575
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync8(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
8917
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync9(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
8576
8918
  privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
8577
8919
  });
8578
8920
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
@@ -8661,7 +9003,7 @@ if (import.meta.main) {
8661
9003
  return [name, process.env[name]];
8662
9004
  })),
8663
9005
  gitCredentialPath: process.env.CREDENTIALS_DIRECTORY ? `${process.env.CREDENTIALS_DIRECTORY}/git-deploy-key` : undefined,
8664
- knownHostsPath: source.knownHosts ? join5(root, "cache", `known-hosts-${key}`) : undefined,
9006
+ knownHostsPath: source.knownHosts ? join6(root, "cache", `known-hosts-${key}`) : undefined,
8665
9007
  knownHostsContent: source.knownHosts,
8666
9008
  cache: deploymentSecrets,
8667
9009
  ensureSoftware: (requirements) => requestSoftware(requirements, process.env.FZ_SOFTWARE_HELPER_SOCKET ?? DEFAULT_SOFTWARE_HELPER_SOCKET),
@@ -8736,7 +9078,7 @@ if (import.meta.main) {
8736
9078
  const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
8737
9079
  const deadline = Date.now() + deadlineMs;
8738
9080
  const remaining = () => Math.max(1, deadline - Date.now());
8739
- const controlClosed = control ? await settleWithin(new Promise((resolve3) => control.close(() => resolve3())), remaining()) : true;
9081
+ const controlClosed = control ? await settleWithin(new Promise((resolve4) => control.close(() => resolve4())), remaining()) : true;
8740
9082
  const pullDrain = pull?.stop() ?? Promise.resolve();
8741
9083
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
8742
9084
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
@@ -8840,6 +9182,8 @@ export {
8840
9182
  requestAgentUpdate,
8841
9183
  renderWarpMdm,
8842
9184
  removeMetalGuest,
9185
+ recoverInterruptedAgentUpdate,
9186
+ readAgentUpdateReceipt,
8843
9187
  pullProvisioningOnce,
8844
9188
  pullMigrationOnce,
8845
9189
  pullDeploymentOnce,
@@ -8877,7 +9221,7 @@ export {
8877
9221
  allocateCpuPool,
8878
9222
  allocateAddress,
8879
9223
  activateAgentRelease,
8880
- VERSION2 as VERSION,
9224
+ VERSION3 as VERSION,
8881
9225
  SUPPORTED_GUEST_IMAGE,
8882
9226
  SOFTWARE_HELPER_UNIT_PATH,
8883
9227
  SOFTWARE_HELPER_GROUP,
@@ -8898,6 +9242,7 @@ export {
8898
9242
  DEFAULT_AGENT_RELEASE_ROOT,
8899
9243
  CacheError,
8900
9244
  AGENT_UPDATE_RECEIPT,
9245
+ AGENT_UPDATE_JOURNAL,
8901
9246
  AGENT_UPDATE_HELPER_UNIT_PATH,
8902
9247
  AGENT_UPDATE_GROUP
8903
9248
  };