@cotal-ai/cli 0.35.0 → 0.36.0
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/commands/clean.d.ts.map +1 -1
- package/dist/commands/clean.js +41 -14
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/down-manifest.js +7 -7
- package/dist/commands/down-manifest.js.map +1 -1
- package/dist/commands/mint.d.ts.map +1 -1
- package/dist/commands/mint.js +4 -3
- package/dist/commands/mint.js.map +1 -1
- package/dist/commands/spawn.d.ts.map +1 -1
- package/dist/commands/spawn.js +25 -22
- package/dist/commands/spawn.js.map +1 -1
- package/dist/commands/up.d.ts +4 -2
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +194 -110
- package/dist/commands/up.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/delivery-proc.d.ts.map +1 -1
- package/dist/lib/delivery-proc.js +20 -6
- package/dist/lib/delivery-proc.js.map +1 -1
- package/dist/lib/manifest/ledger.d.ts +7 -2
- package/dist/lib/manifest/ledger.d.ts.map +1 -1
- package/dist/lib/manifest/ledger.js +9 -4
- package/dist/lib/manifest/ledger.js.map +1 -1
- package/dist/lib/restore.d.ts +6 -6
- package/dist/lib/restore.d.ts.map +1 -1
- package/dist/lib/restore.js +25 -15
- package/dist/lib/restore.js.map +1 -1
- package/package.json +3 -3
package/dist/commands/up.js
CHANGED
|
@@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { createConnection, createServer } from "node:net";
|
|
4
4
|
import { hostname } from "node:os";
|
|
5
5
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, } from "node:fs";
|
|
6
|
-
import { join, resolve } from "node:path";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, standaloneConnectOpts, seedChannelRegistry, ensureDefaultDeliveryClass, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
|
|
8
8
|
import { connect } from "@nats-io/transport-node";
|
|
9
|
-
import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes,
|
|
9
|
+
import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, connectionEvictorCredsKey, membershipConfigPath, membershipObserverCredsKey, membershipRwCredsKey, MEMBERSHIP_CONFIG_KIND, MEMBERSHIP_RW_CREDS_KIND, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, } from "@cotal-ai/workspace";
|
|
10
10
|
import { ensureAuthService, resolveAuthProvider, stopAuthService } from "../lib/auth-proc.js";
|
|
11
11
|
import { resolveSpace } from "../lib/status.js";
|
|
12
12
|
import { c } from "../ui.js";
|
|
@@ -66,7 +66,24 @@ export function upComplete(argv) {
|
|
|
66
66
|
const items = upFlags.map((f) => ({ value: `--${f.name}`, description: f.description }));
|
|
67
67
|
return { items, directive: items.length ? "nofiles" : "default" };
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
/** `inheritedLock` is the root maintenance lock a recovery re-entry hands down (see the re-entry
|
|
70
|
+
* below). Ownership transfers with it: this call releases it at its own release point. */
|
|
71
|
+
export async function up(args, inheritedLock) {
|
|
72
|
+
if (!inheritedLock)
|
|
73
|
+
return await runUp(args);
|
|
74
|
+
// A re-entry owns the inherited lock from the moment it is called, but `runUp` only adopts it
|
|
75
|
+
// part-way in — an early refusal (an unusable `--runtime`, say) throws before that. Release it
|
|
76
|
+
// here in exactly that case, so a refused re-entry never leaves the root locked for the reaper.
|
|
77
|
+
let adopted = false;
|
|
78
|
+
try {
|
|
79
|
+
return await runUp(args, inheritedLock, () => { adopted = true; });
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (!adopted)
|
|
83
|
+
releaseMaintenanceLock(inheritedLock);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function runUp(args, inheritedLock, onAdopt) {
|
|
70
87
|
const values = args.values;
|
|
71
88
|
if (values.restore) {
|
|
72
89
|
if (values.file || values.channels)
|
|
@@ -101,6 +118,11 @@ export async function up(args) {
|
|
|
101
118
|
}
|
|
102
119
|
catch (error) {
|
|
103
120
|
pendingRestores.delete(prepared.attemptId);
|
|
121
|
+
// The one journal writer deliberately NOT handed a lock, and the reason is structural rather
|
|
122
|
+
// than stylistic: no lock is held in this frame either way. This invocation returns just below
|
|
123
|
+
// without ever reaching the `startupLock` acquire, and the re-entry that did hold one released
|
|
124
|
+
// it in its own `finally` on the way out through this very throw. So the helper must take its
|
|
125
|
+
// own — passing something here would mean passing `undefined`, which reads as an oversight.
|
|
104
126
|
markPreparedRestoreDegraded(prepared.root, prepared.attemptId, error.message);
|
|
105
127
|
throw error;
|
|
106
128
|
}
|
|
@@ -127,6 +149,9 @@ export async function up(args) {
|
|
|
127
149
|
const lock = acquireMaintenanceLock(root);
|
|
128
150
|
let pending;
|
|
129
151
|
let recoveredRestore;
|
|
152
|
+
// Set only at the moment the lock is passed to a re-entry, which then owns releasing it. Any
|
|
153
|
+
// other exit from this block — including a throw after `pending` was built — still releases.
|
|
154
|
+
let handedOff = false;
|
|
130
155
|
try {
|
|
131
156
|
const journal = readMaintenanceJournal(root);
|
|
132
157
|
if (journal?.state === "restore-ready") {
|
|
@@ -271,56 +296,64 @@ export async function up(args) {
|
|
|
271
296
|
else if (journal) {
|
|
272
297
|
throw new Error(`cotal up is refused while maintenance state is ${journal.state}; follow the recorded recovery`);
|
|
273
298
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
299
|
+
// The re-entries run INSIDE this block, under the lock the journal was just written with, and
|
|
300
|
+
// inherit it rather than letting it drop. Releasing here and re-acquiring in the nested `up`
|
|
301
|
+
// left the journal reading `resume-intent` with the lock free — the window a concurrent `up`
|
|
302
|
+
// could render the whole-broker server.conf through (design doc §4, P2).
|
|
303
|
+
if (recoveredRestore) {
|
|
304
|
+
handedOff = true;
|
|
305
|
+
try {
|
|
306
|
+
await up({
|
|
307
|
+
...args,
|
|
308
|
+
values: {
|
|
309
|
+
...values,
|
|
310
|
+
__restoreAttempt: recoveredRestore.attemptId,
|
|
311
|
+
space: recoveredRestore.space,
|
|
312
|
+
server: recoveredRestore.server,
|
|
313
|
+
host: recoveredRestore.host,
|
|
314
|
+
"store-dir": recoveredRestore.targetPath,
|
|
315
|
+
runtime: recoveredRestore.runtime,
|
|
316
|
+
detach: recoveredRestore.detached,
|
|
317
|
+
open: recoveredRestore.mode === "open",
|
|
318
|
+
"user-auth": recoveredRestore.mode === "user",
|
|
319
|
+
},
|
|
320
|
+
}, lock);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
// The nested `up` released the inherited lock on its way out, so this takes its own.
|
|
324
|
+
markPendingResumeDegraded(recoveredRestore.attemptId, error instanceof Error ? error.message : String(error));
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
295
328
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
329
|
+
if (pending) {
|
|
330
|
+
handedOff = true;
|
|
331
|
+
try {
|
|
332
|
+
await up({
|
|
333
|
+
...args,
|
|
334
|
+
values: {
|
|
335
|
+
...values,
|
|
336
|
+
__ordinaryResumeAttempt: pending.attemptId,
|
|
337
|
+
space: pending.space,
|
|
338
|
+
server: pending.server,
|
|
339
|
+
"store-dir": pending.storeDir,
|
|
340
|
+
runtime: pending.runtime,
|
|
341
|
+
detach: pending.detached,
|
|
342
|
+
open: pending.mode === "open",
|
|
343
|
+
"user-auth": pending.mode === "user",
|
|
344
|
+
},
|
|
345
|
+
}, lock);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
markPendingResumeDegraded(pending.attemptId, error instanceof Error ? error.message : String(error));
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
299
352
|
}
|
|
300
|
-
return;
|
|
301
353
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
...args,
|
|
306
|
-
values: {
|
|
307
|
-
...values,
|
|
308
|
-
__ordinaryResumeAttempt: pending.attemptId,
|
|
309
|
-
space: pending.space,
|
|
310
|
-
server: pending.server,
|
|
311
|
-
"store-dir": pending.storeDir,
|
|
312
|
-
runtime: pending.runtime,
|
|
313
|
-
detach: pending.detached,
|
|
314
|
-
open: pending.mode === "open",
|
|
315
|
-
"user-auth": pending.mode === "user",
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
markPendingResumeDegraded(pending.attemptId, error instanceof Error ? error.message : String(error));
|
|
321
|
-
throw error;
|
|
322
|
-
}
|
|
323
|
-
return;
|
|
354
|
+
finally {
|
|
355
|
+
if (!handedOff)
|
|
356
|
+
releaseMaintenanceLock(lock);
|
|
324
357
|
}
|
|
325
358
|
}
|
|
326
359
|
const wantUser = Boolean(values["user-auth"]);
|
|
@@ -454,7 +487,31 @@ export async function up(args) {
|
|
|
454
487
|
if (values.runtime)
|
|
455
488
|
await preflightRuntime(values.runtime);
|
|
456
489
|
const resumeAttempt = values.__restoreAttempt ?? values.__ordinaryResumeAttempt;
|
|
457
|
-
|
|
490
|
+
// Held for EVERY `up`, resume re-entry included. A re-entry used to hold no lock at all and still
|
|
491
|
+
// reach the renderer below, so a concurrent `cotal up` could rewrite the whole-broker server.conf
|
|
492
|
+
// — every tenant's trust, unserialized — while a resume was mid-flight (design doc §4, P2). A
|
|
493
|
+
// re-entry INHERITS the recovery's lock rather than taking its own: the recovery journals
|
|
494
|
+
// `resume-intent` under that lock, so releasing it to re-acquire here would leave exactly the
|
|
495
|
+
// window the probe catches — the journal already in a resume state with the lock free.
|
|
496
|
+
//
|
|
497
|
+
// Inheriting makes handing this lock down MANDATORY, not tidy: the lock is not reentrant, and it
|
|
498
|
+
// cannot stale-reap its way out either, because the recorded owner is alive — it is us. A helper
|
|
499
|
+
// that self-acquired would take the "held by a live owner" refusal and fail the resume outright.
|
|
500
|
+
// So EVERY helper below that journals under this lock takes it as a parameter, on both the restore
|
|
501
|
+
// and the ordinary side: the two ADOPT paths (proven-restore and proven-ordinary listeners, which
|
|
502
|
+
// recover by adopting an already-live listener rather than spawning a competitor), the spawn-path
|
|
503
|
+
// listener binds (detached and foreground, restore and ordinary), the dead-listener replacement,
|
|
504
|
+
// `completeResumeActivation` and the manager-commit / activation / degraded writers it calls.
|
|
505
|
+
//
|
|
506
|
+
// Read that list as a rule, not an inventory. The trap is that a call site with no lock ARGUMENT
|
|
507
|
+
// says nothing about whether the CALLEE acquires: the restore-side writers took no parameter and
|
|
508
|
+
// self-acquired, so handing the lock to the sites that visibly named it left `up --restore` dead
|
|
509
|
+
// on its first pass — it died in the listener bind, before any of the seams. If you add a helper
|
|
510
|
+
// that journals, give it `heldLock` and hand it down from here; do not audit by call-site shape.
|
|
511
|
+
// The one caller that does NOT pass it is the prepare-failure path above, where the lock has
|
|
512
|
+
// genuinely not been taken yet in this frame and the helper is meant to take its own.
|
|
513
|
+
let startupLock = inheritedLock ?? acquireMaintenanceLock(cotalRoot());
|
|
514
|
+
onAdopt?.(); // from here the release below owns it, inherited or not
|
|
458
515
|
const releaseStartupLock = () => {
|
|
459
516
|
if (!startupLock)
|
|
460
517
|
return;
|
|
@@ -462,7 +519,10 @@ export async function up(args) {
|
|
|
462
519
|
startupLock = undefined;
|
|
463
520
|
};
|
|
464
521
|
try {
|
|
465
|
-
|
|
522
|
+
// Gated on the ATTEMPT, not on holding the lock. The lock is now always held, and this refuses
|
|
523
|
+
// whenever a journal exists — which is precisely the state a resume re-entry is in, so gating
|
|
524
|
+
// it on the lock would refuse the very resume the lock is here to protect.
|
|
525
|
+
if (!resumeAttempt)
|
|
466
526
|
assertOrdinaryUpAllowed(cotalRoot(), values["store-dir"] ? resolve(values["store-dir"]) : cotalPath("nats"));
|
|
467
527
|
let server = values.server ?? DEFAULT_SERVER;
|
|
468
528
|
const host = values.host ?? "127.0.0.1";
|
|
@@ -480,7 +540,7 @@ export async function up(args) {
|
|
|
480
540
|
throw new Error(`restore attempt ${resumeAttempt} re-entry has no bound listener proof; preserving recovery state`);
|
|
481
541
|
const ownerStatus = localProcessOwnerStatus(restoredAttempt.listenerProof.processOwner);
|
|
482
542
|
if (ownerStatus === "alive") {
|
|
483
|
-
await resumeProvenRestoreListener(restoredAttempt);
|
|
543
|
+
await resumeProvenRestoreListener(restoredAttempt, startupLock);
|
|
484
544
|
return;
|
|
485
545
|
}
|
|
486
546
|
if (ownerStatus === "unknown")
|
|
@@ -489,13 +549,13 @@ export async function up(args) {
|
|
|
489
549
|
throw new Error(`restore attempt ${resumeAttempt} is manager-committed but its bound listener is dead; preserving the commit token and retained suppression`);
|
|
490
550
|
if (await isReachable(restoredAttempt.server))
|
|
491
551
|
throw new Error(`restore attempt ${resumeAttempt} refuses the occupied foreign listener at ${restoredAttempt.server}`);
|
|
492
|
-
replacePreparedDeadRestoreListener(restoredAttempt);
|
|
552
|
+
replacePreparedDeadRestoreListener(restoredAttempt, startupLock);
|
|
493
553
|
}
|
|
494
554
|
const ordinaryAttempt = resumeAttempt ? pendingOrdinaryResumes.get(resumeAttempt) : undefined;
|
|
495
555
|
if (ordinaryAttempt?.adoptProof) {
|
|
496
556
|
// The recovered attempt's exact bound listener is alive: prove it over the wire and adopt it
|
|
497
557
|
// instead of spawning a competitor over the same store.
|
|
498
|
-
await resumeProvenOrdinaryListener(ordinaryAttempt);
|
|
558
|
+
await resumeProvenOrdinaryListener(ordinaryAttempt, startupLock);
|
|
499
559
|
return;
|
|
500
560
|
}
|
|
501
561
|
if (ordinaryAttempt?.journalState === "resume-committed")
|
|
@@ -713,14 +773,14 @@ export async function up(args) {
|
|
|
713
773
|
boundListener: {
|
|
714
774
|
serverName: restored.serverName,
|
|
715
775
|
serverNonce: restored.serverNonce,
|
|
716
|
-
onSpawn: (pid, startedAt) => bindSpawnedRestoreListener(restored, pid, startedAt),
|
|
776
|
+
onSpawn: (pid, startedAt) => bindSpawnedRestoreListener(restored, pid, startedAt, startupLock),
|
|
717
777
|
verify: async () => { await provePreparedRestoreListener(restored); },
|
|
718
778
|
},
|
|
719
779
|
} : ordinaryAttempt ? {
|
|
720
780
|
boundListener: {
|
|
721
781
|
serverName: ordinaryAttempt.serverName,
|
|
722
782
|
serverNonce: ordinaryAttempt.serverNonce,
|
|
723
|
-
onSpawn: (pid, startedAt) => bindSpawnedOrdinaryResumeListener(ordinaryAttempt, pid, startedAt),
|
|
783
|
+
onSpawn: (pid, startedAt) => bindSpawnedOrdinaryResumeListener(ordinaryAttempt, pid, startedAt, startupLock),
|
|
724
784
|
verify: async () => { await verifySpawnedOrdinaryListener(ordinaryAttempt); },
|
|
725
785
|
},
|
|
726
786
|
} : {}),
|
|
@@ -736,7 +796,7 @@ export async function up(args) {
|
|
|
736
796
|
// exit code, not only in the red line above.
|
|
737
797
|
if (!authService)
|
|
738
798
|
process.exitCode = 1;
|
|
739
|
-
await completeResumeActivation(resumeAttempt, controlPlane && authService, !authService ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server);
|
|
799
|
+
await completeResumeActivation(resumeAttempt, controlPlane && authService, !authService ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server, startupLock);
|
|
740
800
|
return;
|
|
741
801
|
}
|
|
742
802
|
const useAuth = !values.open;
|
|
@@ -785,7 +845,7 @@ export async function up(args) {
|
|
|
785
845
|
process.exit(87);
|
|
786
846
|
if (restored)
|
|
787
847
|
try {
|
|
788
|
-
bindSpawnedRestoreListener(restored, child.pid ?? 0, listenerStartedAt);
|
|
848
|
+
bindSpawnedRestoreListener(restored, child.pid ?? 0, listenerStartedAt, startupLock);
|
|
789
849
|
}
|
|
790
850
|
catch (error) {
|
|
791
851
|
await stopUnboundRestoreListener(child);
|
|
@@ -794,7 +854,7 @@ export async function up(args) {
|
|
|
794
854
|
}
|
|
795
855
|
if (ordinaryAttempt)
|
|
796
856
|
try {
|
|
797
|
-
bindSpawnedOrdinaryResumeListener(ordinaryAttempt, child.pid ?? 0, listenerStartedAt);
|
|
857
|
+
bindSpawnedOrdinaryResumeListener(ordinaryAttempt, child.pid ?? 0, listenerStartedAt, startupLock);
|
|
798
858
|
}
|
|
799
859
|
catch (error) {
|
|
800
860
|
await stopUnboundRestoreListener(child);
|
|
@@ -855,7 +915,9 @@ export async function up(args) {
|
|
|
855
915
|
if (!ready) {
|
|
856
916
|
child.kill("SIGTERM");
|
|
857
917
|
const reason = `nats-server did not become ready at ${server}`;
|
|
858
|
-
|
|
918
|
+
// `startupLock` is already released by here on this path (and the helper then takes its own);
|
|
919
|
+
// passed anyway so the call stays correct if the release above ever moves.
|
|
920
|
+
markPendingResumeDegraded(resumeAttempt ?? "", reason, startupLock);
|
|
859
921
|
throw new Error(reason);
|
|
860
922
|
}
|
|
861
923
|
if (restored)
|
|
@@ -910,7 +972,7 @@ export async function up(args) {
|
|
|
910
972
|
});
|
|
911
973
|
if (restored && process.env.COTAL_SMOKE_FAIL_AFTER_RESTORE_LISTENER_READY === "1")
|
|
912
974
|
throw new Error("smoke-injected failure after restore listener readiness");
|
|
913
|
-
await completeResumeActivation(resumeAttempt, controlPlane && svc.ok, !svc.ok ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server);
|
|
975
|
+
await completeResumeActivation(resumeAttempt, controlPlane && svc.ok, !svc.ok ? "normal listener started but the user-auth service is unavailable" : "normal listener started but the control plane is degraded", server, startupLock);
|
|
914
976
|
activationFinished = true;
|
|
915
977
|
}
|
|
916
978
|
await new Promise(() => { });
|
|
@@ -943,10 +1005,10 @@ function assertOrdinaryUpAllowed(root, storeDir) {
|
|
|
943
1005
|
throw new Error("ordinary resume must begin through the attempt-bound startup path");
|
|
944
1006
|
throw new Error(`cotal up is refused while maintenance state is ${maintenance.state}; follow the recorded restore recovery`);
|
|
945
1007
|
}
|
|
946
|
-
function markPendingResumeDegraded(attemptId, reason) {
|
|
1008
|
+
function markPendingResumeDegraded(attemptId, reason, heldLock) {
|
|
947
1009
|
const ordinary = pendingOrdinaryResumes.get(attemptId);
|
|
948
1010
|
if (ordinary) {
|
|
949
|
-
const lock = acquireMaintenanceLock(ordinary.root);
|
|
1011
|
+
const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
|
|
950
1012
|
try {
|
|
951
1013
|
const journal = readMaintenanceJournal(ordinary.root);
|
|
952
1014
|
if (journal && (journal.state === "resume-intent" || journal.state === "resume-active"))
|
|
@@ -957,13 +1019,14 @@ function markPendingResumeDegraded(attemptId, reason) {
|
|
|
957
1019
|
}]);
|
|
958
1020
|
}
|
|
959
1021
|
finally {
|
|
960
|
-
|
|
1022
|
+
if (!heldLock)
|
|
1023
|
+
releaseMaintenanceLock(lock);
|
|
961
1024
|
}
|
|
962
1025
|
return;
|
|
963
1026
|
}
|
|
964
1027
|
const restored = pendingRestores.get(attemptId);
|
|
965
1028
|
if (restored)
|
|
966
|
-
markPreparedRestoreDegraded(restored.root, restored.attemptId, reason);
|
|
1029
|
+
markPreparedRestoreDegraded(restored.root, restored.attemptId, reason, heldLock);
|
|
967
1030
|
}
|
|
968
1031
|
async function resumeControlAuth(root, mode) {
|
|
969
1032
|
if (mode === "open")
|
|
@@ -1021,11 +1084,11 @@ async function stopUnboundRestoreListener(child) {
|
|
|
1021
1084
|
if (!await waitForChildExit(child, 5_000))
|
|
1022
1085
|
throw new Error(`unbound restore listener process ${child.pid ?? "unknown"} did not exit`);
|
|
1023
1086
|
}
|
|
1024
|
-
function bindSpawnedRestoreListener(prepared, pid, startedAt) {
|
|
1025
|
-
bindRestoreListenerOwner(prepared, restoreListenerOwner(pid, prepared.serverNonce, startedAt));
|
|
1087
|
+
function bindSpawnedRestoreListener(prepared, pid, startedAt, heldLock) {
|
|
1088
|
+
bindRestoreListenerOwner(prepared, restoreListenerOwner(pid, prepared.serverNonce, startedAt), heldLock);
|
|
1026
1089
|
}
|
|
1027
|
-
function bindRestoreListenerOwner(prepared, processOwner) {
|
|
1028
|
-
bindPreparedRestoreListener(prepared, processOwner);
|
|
1090
|
+
function bindRestoreListenerOwner(prepared, processOwner, heldLock) {
|
|
1091
|
+
bindPreparedRestoreListener(prepared, processOwner, heldLock);
|
|
1029
1092
|
if (process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_BIND === "1")
|
|
1030
1093
|
process.exit(86);
|
|
1031
1094
|
}
|
|
@@ -1146,10 +1209,10 @@ async function provePreparedRestoreListener(prepared) {
|
|
|
1146
1209
|
}
|
|
1147
1210
|
return proof;
|
|
1148
1211
|
}
|
|
1149
|
-
function bindSpawnedOrdinaryResumeListener(pending, pid, startedAt) {
|
|
1212
|
+
function bindSpawnedOrdinaryResumeListener(pending, pid, startedAt, heldLock) {
|
|
1150
1213
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
1151
1214
|
throw new Error("resume listener spawn returned no pid");
|
|
1152
|
-
const lock = acquireMaintenanceLock(pending.root);
|
|
1215
|
+
const lock = heldLock ?? acquireMaintenanceLock(pending.root);
|
|
1153
1216
|
try {
|
|
1154
1217
|
const journal = readMaintenanceJournal(pending.root);
|
|
1155
1218
|
if (!journal || !("ordinaryResume" in journal) || journal.ordinaryResume.attemptId !== pending.attemptId)
|
|
@@ -1164,7 +1227,8 @@ function bindSpawnedOrdinaryResumeListener(pending, pid, startedAt) {
|
|
|
1164
1227
|
});
|
|
1165
1228
|
}
|
|
1166
1229
|
finally {
|
|
1167
|
-
|
|
1230
|
+
if (!heldLock)
|
|
1231
|
+
releaseMaintenanceLock(lock);
|
|
1168
1232
|
}
|
|
1169
1233
|
}
|
|
1170
1234
|
/** Prove the recovered attempt's live bound listener IS ours end-to-end before adoption: launch
|
|
@@ -1235,7 +1299,7 @@ async function verifySpawnedOrdinaryListener(pending) {
|
|
|
1235
1299
|
if (info.server_name !== pending.serverName)
|
|
1236
1300
|
throw new Error(`resume attempt ${pending.attemptId} spawned listener reports a foreign NATS server name`);
|
|
1237
1301
|
}
|
|
1238
|
-
async function resumeProvenOrdinaryListener(pending) {
|
|
1302
|
+
async function resumeProvenOrdinaryListener(pending, heldLock) {
|
|
1239
1303
|
await proveOrdinaryResumeListener(pending);
|
|
1240
1304
|
const svc = await ensureRecoveredUserAuth(pending);
|
|
1241
1305
|
// Read the recorded exposure BEFORE re-recording: `recordOurMesh` writes the entry whole, so a
|
|
@@ -1259,7 +1323,7 @@ async function resumeProvenOrdinaryListener(pending) {
|
|
|
1259
1323
|
resumeAttempt: pending.attemptId,
|
|
1260
1324
|
resumeCommitToken: pending.managerCommit?.durableCommitToken,
|
|
1261
1325
|
});
|
|
1262
|
-
await completeResumeActivation(pending.attemptId, controlPlane && svc.ok, !svc.ok ? "adopted resume listener has no user-auth service" : "adopted resume listener has a degraded control plane", pending.server);
|
|
1326
|
+
await completeResumeActivation(pending.attemptId, controlPlane && svc.ok, !svc.ok ? "adopted resume listener has no user-auth service" : "adopted resume listener has a degraded control plane", pending.server, heldLock);
|
|
1263
1327
|
}
|
|
1264
1328
|
async function ensureRecoveredUserAuth(prepared) {
|
|
1265
1329
|
if (prepared.mode !== "user")
|
|
@@ -1277,7 +1341,7 @@ async function ensureRecoveredUserAuth(prepared) {
|
|
|
1277
1341
|
});
|
|
1278
1342
|
return startUserAuthService(prepared.space, prepared.server, { prepared: provider, stateDir });
|
|
1279
1343
|
}
|
|
1280
|
-
async function resumeProvenRestoreListener(prepared) {
|
|
1344
|
+
async function resumeProvenRestoreListener(prepared, heldLock) {
|
|
1281
1345
|
await provePreparedRestoreListener(prepared);
|
|
1282
1346
|
const svc = await ensureRecoveredUserAuth(prepared);
|
|
1283
1347
|
// Same ordering constraint as the pending-adopt path above: capture the recorded exposure before
|
|
@@ -1301,9 +1365,9 @@ async function resumeProvenRestoreListener(prepared) {
|
|
|
1301
1365
|
resumeAttempt: prepared.attemptId,
|
|
1302
1366
|
resumeCommitToken: prepared.managerCommit?.durableCommitToken,
|
|
1303
1367
|
});
|
|
1304
|
-
await completeResumeActivation(prepared.attemptId, controlPlane && svc.ok, !svc.ok ? "proven restore listener has no user-auth service" : "proven restore listener has a degraded control plane", prepared.server);
|
|
1368
|
+
await completeResumeActivation(prepared.attemptId, controlPlane && svc.ok, !svc.ok ? "proven restore listener has no user-auth service" : "proven restore listener has a degraded control plane", prepared.server, heldLock);
|
|
1305
1369
|
}
|
|
1306
|
-
async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
1370
|
+
async function completeResumeActivation(attemptId, healthy, reason, server, heldLock) {
|
|
1307
1371
|
if (!attemptId)
|
|
1308
1372
|
return;
|
|
1309
1373
|
const ordinary = pendingOrdinaryResumes.get(attemptId);
|
|
@@ -1312,7 +1376,7 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
|
1312
1376
|
if (!pending)
|
|
1313
1377
|
throw new Error(`resume activation lost attempt context ${attemptId}`);
|
|
1314
1378
|
if (!healthy) {
|
|
1315
|
-
markPendingResumeDegraded(attemptId, reason);
|
|
1379
|
+
markPendingResumeDegraded(attemptId, reason, heldLock);
|
|
1316
1380
|
throw new Error(reason);
|
|
1317
1381
|
}
|
|
1318
1382
|
let journal = readMaintenanceJournal(pending.root);
|
|
@@ -1331,12 +1395,13 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
|
1331
1395
|
if (!("ordinaryResume" in journal) || journal.ordinaryResume.attemptId !== attemptId)
|
|
1332
1396
|
throw new Error(`ordinary resume journal does not match attempt ${attemptId}`);
|
|
1333
1397
|
if (journal.state === "resume-retired") {
|
|
1334
|
-
const lock = acquireMaintenanceLock(ordinary.root);
|
|
1398
|
+
const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
|
|
1335
1399
|
try {
|
|
1336
1400
|
consumeRetiredMaintenance(lock);
|
|
1337
1401
|
}
|
|
1338
1402
|
finally {
|
|
1339
|
-
|
|
1403
|
+
if (!heldLock)
|
|
1404
|
+
releaseMaintenanceLock(lock);
|
|
1340
1405
|
}
|
|
1341
1406
|
pendingOrdinaryResumes.delete(attemptId);
|
|
1342
1407
|
return;
|
|
@@ -1364,7 +1429,7 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
|
1364
1429
|
break;
|
|
1365
1430
|
if (Date.now() >= readinessDeadline) {
|
|
1366
1431
|
const why = ready.error ?? "no manager answered within the readiness deadline";
|
|
1367
|
-
markPendingResumeDegraded(attemptId, why);
|
|
1432
|
+
markPendingResumeDegraded(attemptId, why, heldLock);
|
|
1368
1433
|
throw new Error(why);
|
|
1369
1434
|
}
|
|
1370
1435
|
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
|
|
@@ -1378,13 +1443,13 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
|
1378
1443
|
if (!resumed.ok) {
|
|
1379
1444
|
const detail = resumed.data ? ` (${JSON.stringify(resumed.data)})` : "";
|
|
1380
1445
|
const message = `${resumed.error ?? "retained-agent resume failed"}${detail}`;
|
|
1381
|
-
markPendingResumeDegraded(attemptId, message);
|
|
1446
|
+
markPendingResumeDegraded(attemptId, message, heldLock);
|
|
1382
1447
|
throw new Error(message);
|
|
1383
1448
|
}
|
|
1384
1449
|
if (restored && process.env.COTAL_SMOKE_EXIT_AFTER_RESUME_PRESERVED === "1")
|
|
1385
1450
|
process.exit(88);
|
|
1386
1451
|
if (ordinary && !managerCommit) {
|
|
1387
|
-
const lock = acquireMaintenanceLock(ordinary.root);
|
|
1452
|
+
const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
|
|
1388
1453
|
try {
|
|
1389
1454
|
const current = readMaintenanceJournal(ordinary.root);
|
|
1390
1455
|
if (current?.state !== "resume-active") {
|
|
@@ -1398,35 +1463,37 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
|
1398
1463
|
ordinary.journalState = "resume-active";
|
|
1399
1464
|
}
|
|
1400
1465
|
finally {
|
|
1401
|
-
|
|
1466
|
+
if (!heldLock)
|
|
1467
|
+
releaseMaintenanceLock(lock);
|
|
1402
1468
|
}
|
|
1403
1469
|
}
|
|
1404
1470
|
if (!managerCommit) {
|
|
1405
1471
|
const committed = await askManager(pending.space, server, "commitResume", { attemptId }, auth, "any", 40_000);
|
|
1406
1472
|
if (!committed.ok) {
|
|
1407
1473
|
const message = committed.error ?? "manager resume commit failed";
|
|
1408
|
-
markPendingResumeDegraded(attemptId, message);
|
|
1474
|
+
markPendingResumeDegraded(attemptId, message, heldLock);
|
|
1409
1475
|
throw new Error(message);
|
|
1410
1476
|
}
|
|
1411
1477
|
if (!isManagerCommitResult(committed.data, attemptId)) {
|
|
1412
1478
|
const message = `manager resume commit returned invalid awaiting-finalize evidence for attempt ${attemptId}`;
|
|
1413
|
-
markPendingResumeDegraded(attemptId, message);
|
|
1479
|
+
markPendingResumeDegraded(attemptId, message, heldLock);
|
|
1414
1480
|
throw new Error(message);
|
|
1415
1481
|
}
|
|
1416
1482
|
managerCommit = committed.data;
|
|
1417
1483
|
if (ordinary) {
|
|
1418
|
-
const lock = acquireMaintenanceLock(ordinary.root);
|
|
1484
|
+
const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
|
|
1419
1485
|
try {
|
|
1420
1486
|
recordOrdinaryResumeManagerCommit(lock, managerCommit);
|
|
1421
1487
|
ordinary.journalState = "resume-committed";
|
|
1422
1488
|
ordinary.managerCommit = managerCommit;
|
|
1423
1489
|
}
|
|
1424
1490
|
finally {
|
|
1425
|
-
|
|
1491
|
+
if (!heldLock)
|
|
1492
|
+
releaseMaintenanceLock(lock);
|
|
1426
1493
|
}
|
|
1427
1494
|
}
|
|
1428
1495
|
else {
|
|
1429
|
-
recordPreparedRestoreManagerCommit(restored, managerCommit);
|
|
1496
|
+
recordPreparedRestoreManagerCommit(restored, managerCommit, heldLock);
|
|
1430
1497
|
}
|
|
1431
1498
|
if (process.env.COTAL_SMOKE_EXIT_AFTER_RESUME_COMMIT === "1")
|
|
1432
1499
|
process.exit(89);
|
|
@@ -1457,19 +1524,20 @@ async function completeResumeActivation(attemptId, healthy, reason, server) {
|
|
|
1457
1524
|
if (process.env.COTAL_SMOKE_EXIT_AFTER_RESUME_FINALIZE === "1")
|
|
1458
1525
|
process.exit(91);
|
|
1459
1526
|
if (ordinary) {
|
|
1460
|
-
const lock = acquireMaintenanceLock(ordinary.root);
|
|
1527
|
+
const lock = heldLock ?? acquireMaintenanceLock(ordinary.root);
|
|
1461
1528
|
try {
|
|
1462
1529
|
retireOrdinaryResume(lock, finalizeEvidence);
|
|
1463
1530
|
consumeRetiredMaintenance(lock);
|
|
1464
1531
|
}
|
|
1465
1532
|
finally {
|
|
1466
|
-
|
|
1533
|
+
if (!heldLock)
|
|
1534
|
+
releaseMaintenanceLock(lock);
|
|
1467
1535
|
}
|
|
1468
1536
|
pendingOrdinaryResumes.delete(attemptId);
|
|
1469
1537
|
}
|
|
1470
1538
|
else {
|
|
1471
1539
|
restored.managerCommit = managerCommit;
|
|
1472
|
-
markPreparedRestoreActive(restored, finalizeEvidence);
|
|
1540
|
+
markPreparedRestoreActive(restored, finalizeEvidence, heldLock);
|
|
1473
1541
|
restored.cleanupStage();
|
|
1474
1542
|
pendingRestores.delete(attemptId);
|
|
1475
1543
|
}
|
|
@@ -2340,7 +2408,7 @@ transport, rotateSys = false) {
|
|
|
2340
2408
|
if (!auth) {
|
|
2341
2409
|
auth = await createSpaceAuth(space);
|
|
2342
2410
|
await putSpaceAuth(store, auth); // strips the $SYS seed at rest, but leaves the in-memory `auth` intact …
|
|
2343
|
-
await provisionMembershipCreds(auth, cotalRoot()); // … so the observer can still be minted here (fresh-space only)
|
|
2411
|
+
await provisionMembershipCreds(auth, cotalRoot(), space); // … so the observer can still be minted here (fresh-space only)
|
|
2344
2412
|
// A fresh space's $SYS material was just minted from the seed that only exists in this branch, so
|
|
2345
2413
|
// the ASK is already satisfied, so say so rather than rotating a one-second-old account, and never
|
|
2346
2414
|
// report a rotation that did not happen.
|
|
@@ -2376,7 +2444,7 @@ transport, rotateSys = false) {
|
|
|
2376
2444
|
console.log(c.dim(" NOTE: full backups taken before this rotation can no longer be restored (they are bound to the retired trust chain) - take a fresh `cotal backup` once the mesh is up."));
|
|
2377
2445
|
}
|
|
2378
2446
|
// The DATA half of the membership bundle, on EVERY path — see healMembershipDataCreds.
|
|
2379
|
-
await healMembershipDataCreds(auth, cotalRoot());
|
|
2447
|
+
await healMembershipDataCreds(auth, cotalRoot(), space);
|
|
2380
2448
|
// The $SYS creds must be signed by the system account THIS boot is about to put in `server.conf`.
|
|
2381
2449
|
// A rotation that committed the trust record and then died leaves them stale, unexpired, and
|
|
2382
2450
|
// broker-dead and, crash-before-either-write, stale in a way that no comparison between the two
|
|
@@ -2389,7 +2457,7 @@ transport, rotateSys = false) {
|
|
|
2389
2457
|
// same pair, so booting here would silently downgrade revocation to deny-new for the life of the
|
|
2390
2458
|
// mesh. The repo's posture is to throw rather than degrade, and the recovery is one command that
|
|
2391
2459
|
// this message names.
|
|
2392
|
-
const stale = staleSystemCreds(cotalRoot(), auth.sys.pub);
|
|
2460
|
+
const stale = staleSystemCreds(cotalRoot(), auth.sys.pub, space);
|
|
2393
2461
|
if (stale.length)
|
|
2394
2462
|
throw new Error(`${stale.map((x) => `${x.file} (signed by ${x.iss ? `${x.iss.slice(0, 12)}…` : "an unreadable issuer"})`).join(", ")} ` +
|
|
2395
2463
|
`${stale.length === 1 ? "is" : "are"} not signed by this space's system account (${auth.sys.pub.slice(0, 12)}…) - ` +
|
|
@@ -2528,18 +2596,26 @@ async function assertRootBrokerStopped(root) {
|
|
|
2528
2596
|
* its own error text — advice that could not succeed. Healing here fixes both spellings at once:
|
|
2529
2597
|
* the ordinary `up` repairs the data half with no rotation at all, and a rotation now repairs it
|
|
2530
2598
|
* too, because this runs after both branches converge. */
|
|
2531
|
-
async function healMembershipDataCreds(auth, root) {
|
|
2599
|
+
async function healMembershipDataCreds(auth, root, space) {
|
|
2532
2600
|
try {
|
|
2533
2601
|
const store = workspaceSecretStore(root);
|
|
2602
|
+
// THE RESOLVERS, not the bare kinds (P7 §2 rule 1). Both reads below are absent-means-MINT, which
|
|
2603
|
+
// is precisely why the location must be resolved through the choke point: a canonical read on a
|
|
2604
|
+
// root whose material is still flat answers "absent" and mints a SECOND live cred beside the one
|
|
2605
|
+
// the running daemons hold. `up` is a workstation composition by construction, so the resolver
|
|
2606
|
+
// gets the FS arm and moves the material on this first touch.
|
|
2607
|
+
const composition = { injected: false, root };
|
|
2608
|
+
const rwKey = membershipRwCredsKey(space, composition);
|
|
2534
2609
|
const wrote = [];
|
|
2535
|
-
if ((await store.get(
|
|
2536
|
-
await store.put(
|
|
2537
|
-
wrote.push(
|
|
2610
|
+
if ((await store.get(rwKey)) === undefined) {
|
|
2611
|
+
await store.put(rwKey, await mintCreds(auth, newIdentity(), "membership-rw"));
|
|
2612
|
+
wrote.push(MEMBERSHIP_RW_CREDS_KIND); // the KIND is what an operator reads, never the segmented key
|
|
2538
2613
|
}
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2614
|
+
const configPath = membershipConfigPath(root, space);
|
|
2615
|
+
if (!existsSync(configPath)) {
|
|
2616
|
+
mkSecretDir(dirname(configPath)); // harden the per-space dir before the file lands, as the fresh path does
|
|
2617
|
+
writeSecretFile(configPath, JSON.stringify({ accountId: auth.account.pub }));
|
|
2618
|
+
wrote.push(MEMBERSHIP_CONFIG_KIND);
|
|
2543
2619
|
}
|
|
2544
2620
|
if (wrote.length)
|
|
2545
2621
|
console.log(c.dim(`• membership: provisioned ${wrote.join(" + ")} - the data-account half needs no system-account rotation`));
|
|
@@ -2550,7 +2626,7 @@ async function healMembershipDataCreds(auth, root) {
|
|
|
2550
2626
|
console.error(c.dim(`• broker-sourced membership not repaired: ${e.message}`));
|
|
2551
2627
|
}
|
|
2552
2628
|
}
|
|
2553
|
-
async function provisionMembershipCreds(auth, root) {
|
|
2629
|
+
async function provisionMembershipCreds(auth, root, space) {
|
|
2554
2630
|
try {
|
|
2555
2631
|
const observer = await mintMembershipObserverCreds(auth, newIdentity());
|
|
2556
2632
|
const rw = await mintCreds(auth, newIdentity(), "membership-rw");
|
|
@@ -2559,11 +2635,19 @@ async function provisionMembershipCreds(auth, root) {
|
|
|
2559
2635
|
// close a revoked/removed principal's live connections. A space without it degrades to
|
|
2560
2636
|
// deny-new-only (durable reauth) — surfaced loudly by the removal path, never silent.
|
|
2561
2637
|
const evictor = await mintConnectionEvictorCreds(auth, newIdentity());
|
|
2562
|
-
|
|
2563
|
-
writeSecretFile
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2638
|
+
// All four land through the per-kind resolvers (P7 §2 rule 1), and all four through the store's
|
|
2639
|
+
// `put`, which is the same `mkSecretDir` + atomic write the raw `writeSecretFile` did — the
|
|
2640
|
+
// difference being that it hardens the PER-SPACE dir the material now lives in rather than
|
|
2641
|
+
// `.cotal/` itself. This is the fresh-space branch, so nothing is there to migrate; it calls the
|
|
2642
|
+
// resolvers anyway because a kind with two write paths is a kind that grows two layouts.
|
|
2643
|
+
const composition = { injected: false, root };
|
|
2644
|
+
const store = workspaceSecretStore(root);
|
|
2645
|
+
await store.put(membershipObserverCredsKey(space, composition), observer);
|
|
2646
|
+
await store.put(membershipRwCredsKey(space, composition), rw);
|
|
2647
|
+
await store.put(connectionEvictorCredsKey(space, composition), evictor);
|
|
2648
|
+
const configPath = membershipConfigPath(root, space);
|
|
2649
|
+
mkSecretDir(dirname(configPath));
|
|
2650
|
+
writeSecretFile(configPath, JSON.stringify({ accountId: auth.account.pub }));
|
|
2567
2651
|
}
|
|
2568
2652
|
catch (e) {
|
|
2569
2653
|
console.error(c.dim(`• broker-sourced membership not provisioned: ${e.message}`));
|